Skip to main content
On this page

REST endpoints with SvelteKit +server.ts files.


Strategy

File-based API routes using Web Standards (Request/Response).

Aspect Approach
Endpoints +server.ts files
Validation Valibot schemas
Errors error() helper + status codes
Documentation OpenAPI (optional)
CORS hooks.server.ts

Basic Endpoints

File Structure

src/routes/
├── api/
│   ├── health/
│   │   └── +server.ts          # GET /api/health
│   ├── items/
│   │   ├── +server.ts          # GET, POST /api/items
│   │   └── [id]/
│   │       └── +server.ts      # GET, PUT, DELETE /api/items/:id
│   └── upload/
│       └── +server.ts          # POST /api/upload

GET Handler

// src/routes/api/items/+server.ts
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { items } from '$lib/server/db/schema';

export const GET: RequestHandler = async ({ url }) => {
  const limit = Number(url.searchParams.get('limit')) || 20;
  const offset = Number(url.searchParams.get('offset')) || 0;

  const results = await db
    .select()
    .from(items)
    .limit(limit)
    .offset(offset);

  return json({
    data: results,
    meta: { limit, offset },
  });
};

POST Handler

// src/routes/api/items/+server.ts
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { items } from '$lib/server/db/schema';
import * as v from 'valibot';

const CreateItemSchema = v.object({
  name: v.pipe(v.string(), v.minLength(1), v.maxLength(100)),
  description: v.optional(v.string()),
  price: v.pipe(v.number(), v.minValue(0)),
});

export const POST: RequestHandler = async ({ request }) => {
  const body = await request.json();

  const result = v.safeParse(CreateItemSchema, body);
  if (!result.success) {
    error(400, {
      message: 'Validation failed',
      errors: result.issues.map(i => ({
        path: i.path?.map(p => p.key).join('.'),
        message: i.message,
      })),
    });
  }

  const [item] = await db
    .insert(items)
    .values(result.output)
    .returning();

  return json(item, { status: 201 });
};

PUT Handler

// src/routes/api/items/[id]/+server.ts
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { items } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import * as v from 'valibot';

const UpdateItemSchema = v.object({
  name: v.optional(v.pipe(v.string(), v.minLength(1), v.maxLength(100))),
  description: v.optional(v.string()),
  price: v.optional(v.pipe(v.number(), v.minValue(0))),
});

export const PUT: RequestHandler = async ({ params, request }) => {
  const body = await request.json();

  const result = v.safeParse(UpdateItemSchema, body);
  if (!result.success) {
    error(400, { message: 'Validation failed' });
  }

  const [updated] = await db
    .update(items)
    .set({ ...result.output, updatedAt: new Date() })
    .where(eq(items.id, params.id))
    .returning();

  if (!updated) {
    error(404, { message: 'Item not found' });
  }

  return json(updated);
};

DELETE Handler

// src/routes/api/items/[id]/+server.ts
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { items } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';

export const DELETE: RequestHandler = async ({ params }) => {
  const [deleted] = await db
    .delete(items)
    .where(eq(items.id, params.id))
    .returning();

  if (!deleted) {
    error(404, { message: 'Item not found' });
  }

  return json({ success: true });
};

Request Handling

URL Parameters

export const GET: RequestHandler = async ({ params, url }) => {
  // Route params: /api/items/[id] → params.id
  const { id } = params;

  // Query params: /api/items?sort=name&order=desc
  const sort = url.searchParams.get('sort') ?? 'createdAt';
  const order = url.searchParams.get('order') ?? 'desc';

  // ...
};

Request Body

export const POST: RequestHandler = async ({ request }) => {
  // JSON body
  const json = await request.json();

  // Form data
  const formData = await request.formData();
  const name = formData.get('name');
  const file = formData.get('file') as File;

  // Raw text
  const text = await request.text();

  // ...
};

Headers and Cookies

export const GET: RequestHandler = async ({ request, cookies }) => {
  // Request headers
  const auth = request.headers.get('Authorization');
  const contentType = request.headers.get('Content-Type');

  // Cookies
  const session = cookies.get('session');

  // Response with custom headers
  return new Response(JSON.stringify(data), {
    headers: {
      'Content-Type': 'application/json',
      'Cache-Control': 'max-age=3600',
      'X-Custom-Header': 'value',
    },
  });
};

Validation

Valibot Schemas

// src/lib/server/api/schemas.ts
import * as v from 'valibot';

// Reusable schemas
export const PaginationSchema = v.object({
  limit: v.optional(v.pipe(v.number(), v.minValue(1), v.maxValue(100)), 20),
  offset: v.optional(v.pipe(v.number(), v.minValue(0)), 0),
});

export const IdParamSchema = v.object({
  id: v.pipe(v.string(), v.uuid()),
});

// Entity schemas
export const CreateItemSchema = v.object({
  name: v.pipe(v.string(), v.minLength(1), v.maxLength(100)),
  description: v.optional(v.pipe(v.string(), v.maxLength(1000))),
  price: v.pipe(v.number(), v.minValue(0)),
  tags: v.optional(v.array(v.string())),
});

export const UpdateItemSchema = v.partial(CreateItemSchema);

Validation Helper

// src/lib/server/api/validate.ts
import { error } from '@sveltejs/kit';
import * as v from 'valibot';

export function validate<T extends v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>>(
  schema: T,
  data: unknown
): v.InferOutput<T> {
  const result = v.safeParse(schema, data);

  if (!result.success) {
    error(400, {
      message: 'Validation failed',
      errors: result.issues.map(issue => ({
        path: issue.path?.map(p => p.key).join('.') ?? '',
        message: issue.message,
      })),
    });
  }

  return result.output;
}

export async function validateBody<T extends v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>>(
  request: Request,
  schema: T
): Promise<v.InferOutput<T>> {
  try {
    const body = await request.json();
    return validate(schema, body);
  } catch (e) {
    if (e instanceof SyntaxError) {
      error(400, { message: 'Invalid JSON' });
    }
    throw e;
  }
}

export function validateQuery<T extends v.BaseSchema<unknown, unknown, v.BaseIssue<unknown>>>(
  url: URL,
  schema: T
): v.InferOutput<T> {
  const params = Object.fromEntries(url.searchParams);
  return validate(schema, params);
}

Usage

import { validateBody, validateQuery } from '$lib/server/api/validate';
import { CreateItemSchema, PaginationSchema } from '$lib/server/api/schemas';

export const GET: RequestHandler = async ({ url }) => {
  const { limit, offset } = validateQuery(url, PaginationSchema);
  // ...
};

export const POST: RequestHandler = async ({ request }) => {
  const data = await validateBody(request, CreateItemSchema);
  // data is fully typed
};

Error Handling

Expected Errors

import { error } from '@sveltejs/kit';

export const GET: RequestHandler = async ({ params }) => {
  const item = await db.query.items.findFirst({
    where: eq(items.id, params.id),
  });

  if (!item) {
    // 4xx errors - client's fault
    error(404, { message: 'Item not found' });
  }

  if (!item.published) {
    error(403, { message: 'Item not accessible' });
  }

  return json(item);
};

Error Response Format

// Consistent error shape
interface ApiError {
  message: string;
  code?: string;
  errors?: Array<{
    path: string;
    message: string;
  }>;
}

// Usage
error(400, {
  message: 'Validation failed',
  code: 'VALIDATION_ERROR',
  errors: [
    { path: 'email', message: 'Invalid email format' },
    { path: 'password', message: 'Must be at least 8 characters' },
  ],
});

Global Error Handler

// src/hooks.server.ts
import type { HandleServerError } from '@sveltejs/kit';

export const handleError: HandleServerError = async ({ error, event, status, message }) => {
  // Log unexpected errors (5xx)
  if (status >= 500) {
    console.error('Server error:', error);
    // Send to error tracking service
    // await sentry.captureException(error);
  }

  return {
    message: status >= 500 ? 'Internal server error' : message,
    code: status >= 500 ? 'INTERNAL_ERROR' : undefined,
  };
};

Authentication

Protected Endpoints

// src/routes/api/items/+server.ts
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';

export const POST: RequestHandler = async ({ locals, request }) => {
  // Check auth from hooks.server.ts
  if (!locals.user) {
    error(401, { message: 'Unauthorized' });
  }

  // Check permissions
  if (!locals.user.canCreateItems) {
    error(403, { message: 'Forbidden' });
  }

  // Proceed with authenticated request
  const data = await validateBody(request, CreateItemSchema);
  // ...
};

API Key Authentication

Security: Never use === for secret comparison—it's vulnerable to timing attacks. Use crypto.timingSafeEqual() instead.

// src/lib/server/auth/api-key.ts
import { timingSafeEqual } from 'crypto';
import { API_SECRET_KEY } from '$env/static/private';

/**
 * Timing-safe API key verification.
 * Prevents timing attacks by ensuring constant-time comparison.
 */
export function verifyApiKey(providedKey: string | null): boolean {
  if (!providedKey || !API_SECRET_KEY) {
    return false;
  }

  // Length check first (not timing-safe, but prevents unnecessary encoding)
  if (providedKey.length !== API_SECRET_KEY.length) {
    return false;
  }

  const providedBuffer = Buffer.from(providedKey);
  const expectedBuffer = Buffer.from(API_SECRET_KEY);

  return timingSafeEqual(providedBuffer, expectedBuffer);
}
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
import { verifyApiKey } from '$lib/server/auth/api-key';

export const handle: Handle = async ({ event, resolve }) => {
  // Check for API routes
  if (event.url.pathname.startsWith('/api')) {
    const apiKey = event.request.headers.get('X-API-Key');

    if (verifyApiKey(apiKey)) {
      event.locals.apiAuth = true;
    }
  }

  return resolve(event);
};

CORS

Global CORS in Hooks

CORS is implemented as a composable handler using sequence. See auth.md for the full hooks.server.ts setup.

// src/lib/server/hooks/cors.ts
import type { Handle } from '@sveltejs/kit';

const ALLOWED_ORIGINS = [
  'https://example.com',
  'https://app.example.com',
];

export const corsHandle: Handle = async ({ event, resolve }) => {
  // Handle preflight requests
  if (event.request.method === 'OPTIONS') {
    const origin = event.request.headers.get('Origin');

    if (origin && ALLOWED_ORIGINS.includes(origin)) {
      return new Response(null, {
        headers: {
          'Access-Control-Allow-Origin': origin,
          'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
          'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-API-Key',
          'Access-Control-Max-Age': '86400',
        },
      });
    }
  }

  const response = await resolve(event);

  // Add CORS headers to API responses
  if (event.url.pathname.startsWith('/api')) {
    const origin = event.request.headers.get('Origin');

    if (origin && ALLOWED_ORIGINS.includes(origin)) {
      response.headers.set('Access-Control-Allow-Origin', origin);
      response.headers.set('Access-Control-Allow-Credentials', 'true');
    }
  }

  return response;
};

Composing with Auth

// src/hooks.server.ts
import { sequence } from '@sveltejs/kit/hooks';
import { authHandle, sessionHandle } from '$lib/server/hooks/auth';
import { corsHandle } from '$lib/server/hooks/cors';

// Order matters: CORS first (handles OPTIONS), then auth
export const handle = sequence(corsHandle, authHandle, sessionHandle);

Why sequence? SvelteKit only allows one handle export. Use sequence to compose CORS, auth, logging, and other middleware-like handlers.

Per-Route CORS

// src/routes/api/public/+server.ts
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';

export const GET: RequestHandler = async () => {
  const data = { /* ... */ };

  return json(data, {
    headers: {
      'Access-Control-Allow-Origin': '*',
    },
  });
};

// Handle OPTIONS for this specific route
export const OPTIONS: RequestHandler = async () => {
  return new Response(null, {
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'GET, OPTIONS',
    },
  });
};

Response Helpers

Standard Response Wrapper

// src/lib/server/api/response.ts
import { json } from '@sveltejs/kit';

interface ApiResponse<T> {
  data: T;
  meta?: Record<string, unknown>;
}

interface PaginatedResponse<T> extends ApiResponse<T[]> {
  meta: {
    total: number;
    limit: number;
    offset: number;
    hasMore: boolean;
  };
}

export function apiResponse<T>(data: T, meta?: Record<string, unknown>) {
  return json({ data, meta });
}

export function paginatedResponse<T>(
  data: T[],
  total: number,
  limit: number,
  offset: number
) {
  return json({
    data,
    meta: {
      total,
      limit,
      offset,
      hasMore: offset + data.length < total,
    },
  });
}

Usage

import { paginatedResponse } from '$lib/server/api/response';

export const GET: RequestHandler = async ({ url }) => {
  const limit = Number(url.searchParams.get('limit')) || 20;
  const offset = Number(url.searchParams.get('offset')) || 0;

  const [results, [{ count }]] = await Promise.all([
    db.select().from(items).limit(limit).offset(offset),
    db.select({ count: sql`count(*)` }).from(items),
  ]);

  return paginatedResponse(results, Number(count), limit, offset);
};

File Uploads

Security: Never trust client-provided MIME types (file.type). Validate actual file content using magic bytes.

Required: "file-type": "^19.x" — see development-environment.md

// src/lib/server/upload/validate.ts
import { fileTypeFromBuffer } from 'file-type';

const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'] as const;
type AllowedMimeType = (typeof ALLOWED_MIME_TYPES)[number];

interface ValidatedFile {
  buffer: Buffer;
  mimeType: AllowedMimeType;
  extension: string;
}

/**
 * Validates file content using magic bytes, not client-provided MIME type.
 * Prevents attackers from uploading malicious files with spoofed extensions.
 */
export async function validateFileContent(file: File): Promise<ValidatedFile> {
  const buffer = Buffer.from(await file.arrayBuffer());
  const detected = await fileTypeFromBuffer(buffer);

  if (!detected) {
    throw new Error('Unable to determine file type');
  }

  if (!ALLOWED_MIME_TYPES.includes(detected.mime as AllowedMimeType)) {
    throw new Error(`File type ${detected.mime} not allowed`);
  }

  return {
    buffer,
    mimeType: detected.mime as AllowedMimeType,
    extension: detected.ext,
  };
}
// src/routes/api/upload/+server.ts
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { validateFileContent } from '$lib/server/upload/validate';
import { uploadToR2 } from '$lib/server/storage';

const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB

export const POST: RequestHandler = async ({ request, locals }) => {
  if (!locals.user) {
    error(401, { message: 'Unauthorized' });
  }

  const formData = await request.formData();
  const fileEntry = formData.get('file');

  // Validate file exists and is actually a File (not a string)
  if (!fileEntry || typeof fileEntry === 'string') {
    error(400, { message: 'No file provided' });
  }

  const file = fileEntry as File;

  if (file.size > MAX_FILE_SIZE) {
    error(400, { message: 'File too large (max 10MB)' });
  }

  // Validate actual file content using magic bytes
  let validated;
  try {
    validated = await validateFileContent(file);
  } catch (e) {
    error(400, { message: e instanceof Error ? e.message : 'Invalid file type' });
  }

  // Generate safe filename (never use client-provided name)
  const safeFilename = `${crypto.randomUUID()}.${validated.extension}`;

  const url = await uploadToR2(validated.buffer, safeFilename, locals.user.id);

  return json({ url }, { status: 201 });
};

Rate Limiting

// src/lib/server/api/ratelimit.ts
import { RateLimiter } from 'sveltekit-rate-limiter/server';

export const apiLimiter = new RateLimiter({
  IP: [100, '15m'],      // 100 requests per 15 minutes per IP
  IPUA: [200, '15m'],    // 200 per IP + User Agent combo
});

export const strictLimiter = new RateLimiter({
  IP: [10, '1m'],        // 10 requests per minute
});
// src/routes/api/items/+server.ts
import { error } from '@sveltejs/kit';
import { apiLimiter } from '$lib/server/api/ratelimit';

export const POST: RequestHandler = async (event) => {
  if (await apiLimiter.isLimited(event)) {
    error(429, { message: 'Too many requests' });
  }

  // ...
};

OpenAPI Documentation (Optional)

Using JSDoc Annotations

// src/routes/api/items/+server.ts

/**
 * @swagger
 * /api/items:
 *   get:
 *     summary: List all items
 *     parameters:
 *       - name: limit
 *         in: query
 *         schema:
 *           type: integer
 *           default: 20
 *       - name: offset
 *         in: query
 *         schema:
 *           type: integer
 *           default: 0
 *     responses:
 *       200:
 *         description: List of items
 */
export const GET: RequestHandler = async ({ url }) => {
  // ...
};

Tools

  • sveltekit-openapi-generator - Generates OpenAPI from JSDoc
  • swagger-ui-svelte - Swagger UI component for Svelte
  • sveltekit-api - Type-safe endpoints with auto OpenAPI

GraphQL (Optional)

GraphQL is not the primary API pattern for Velociraptor. REST endpoints with +server.ts are simpler and sufficient for most use cases. However, GraphQL is available as a showcase for learning and exploration.

When to Consider GraphQL

Use Case REST GraphQL
Simple CRUD Better Overkill
Mobile apps (bandwidth) Good Better
Complex nested queries Multiple requests Single query
Rapid frontend iteration Schema changes Flexible queries
Public API Simpler More powerful

Setup with GraphQL Yoga

GraphQL Yoga is lightweight (~15KB) and works well with SvelteKit.

Required: "graphql": "^16.x", "graphql-yoga": "^5.x" — see development-environment.md

Schema Definition

// src/lib/server/graphql/schema.ts
import { createSchema } from 'graphql-yoga';
import { db } from '$lib/server/db';
import { items, tags } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';

export const schema = createSchema({
  typeDefs: `
    type Item {
      id: ID!
      title: String!
      description: String
      status: ItemStatus!
      tags: [Tag!]!
      createdAt: String!
    }

    type Tag {
      id: ID!
      name: String!
      color: String!
    }

    enum ItemStatus {
      DRAFT
      PUBLISHED
      ARCHIVED
    }

    type Query {
      items(status: ItemStatus): [Item!]!
      item(id: ID!): Item
    }

    type Mutation {
      createItem(title: String!, description: String): Item!
      updateItem(id: ID!, title: String, description: String, status: ItemStatus): Item
      deleteItem(id: ID!): Boolean!
    }
  `,
  resolvers: {
    Query: {
      items: async (_, { status }) => {
        const query = status
          ? db.query.items.findMany({ where: eq(items.status, status.toLowerCase()) })
          : db.query.items.findMany();
        return query;
      },
      item: async (_, { id }) => {
        return db.query.items.findFirst({ where: eq(items.id, id) });
      },
    },
    Mutation: {
      createItem: async (_, { title, description }, context) => {
        if (!context.user) throw new Error('Unauthorized');
        const [item] = await db.insert(items).values({
          id: createId.item(),
          userId: context.user.id,
          title,
          description,
        }).returning();
        return item;
      },
      // ... other mutations
    },
    Item: {
      tags: async (parent) => {
        return db.query.itemTags.findMany({
          where: eq(itemTags.itemId, parent.id),
          with: { tag: true },
        }).then(results => results.map(r => r.tag));
      },
    },
  },
});

SvelteKit Integration

// src/routes/api/graphql/+server.ts
import { createYoga } from 'graphql-yoga';
import { schema } from '$lib/server/graphql/schema';
import type { RequestHandler } from './$types';

const yoga = createYoga({
  schema,
  graphqlEndpoint: '/api/graphql',
  fetchAPI: globalThis,
});

export const GET: RequestHandler = async ({ request, locals }) => {
  return yoga.handleRequest(request, { user: locals.user });
};

export const POST: RequestHandler = async ({ request, locals }) => {
  return yoga.handleRequest(request, { user: locals.user });
};

GraphiQL Playground

GraphQL Yoga includes GraphiQL by default. Visit /api/graphql in your browser.

To disable in production:

const yoga = createYoga({
  schema,
  graphqlEndpoint: '/api/graphql',
  graphiql: process.env.NODE_ENV !== 'production',
});

Client Usage

// src/lib/graphql-client.ts
export async function graphql<T>(
  query: string,
  variables?: Record<string, unknown>
): Promise<T> {
  const response = await fetch('/api/graphql', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ query, variables }),
  });

  const { data, errors } = await response.json();
  if (errors) throw new Error(errors[0].message);
  return data;
}
<script lang="ts">
  import { graphql } from '$lib/graphql-client';

  const loadItems = async () => {
    const data = await graphql<{ items: Item[] }>(`
      query {
        items(status: PUBLISHED) {
          id
          title
          tags { name color }
        }
      }
    `);
    return data.items;
  };
</script>

When NOT to Use GraphQL

  • Simple CRUD operations → use REST
  • Public APIs → REST is more cacheable
  • Server-to-server communication → REST is simpler
  • You don't have complex nested data → REST is sufficient

Velociraptor's recommendation: Start with REST. Add GraphQL only if you have specific needs (mobile optimization, complex nested queries, or a public API for developers).


File Structure

src/
├── lib/
│   └── server/
│       ├── api/
│       │   ├── schemas.ts      # Valibot schemas
│       │   ├── validate.ts     # Validation helpers
│       │   ├── response.ts     # Response helpers
│       │   └── ratelimit.ts    # Rate limiting
│       └── graphql/            # Optional GraphQL
│           └── schema.ts       # GraphQL schema + resolvers
├── routes/
│   └── api/
│       ├── health/
│       │   └── +server.ts
│       ├── items/
│       │   ├── +server.ts      # GET (list), POST (create)
│       │   └── [id]/
│       │       └── +server.ts  # GET, PUT, DELETE
│       ├── graphql/            # Optional GraphQL endpoint
│       │   └── +server.ts
│       └── upload/
│           └── +server.ts
└── hooks.server.ts             # CORS, error handling

Summary

What How
Endpoints +server.ts with HTTP method exports
Validation Valibot schemas + helper functions
Errors error() with status codes 4xx/5xx
Auth Check locals.user from hooks
CORS hooks.server.ts + OPTIONS handler
Rate limiting sveltekit-rate-limiter
Documentation JSDoc + OpenAPI generator

  • auth.md - Authentication patterns, protected endpoint implementation
  • db/relational.md - Drizzle schema used in API queries
  • db/graph.md - Neo4j for relationship queries
  • pages.md - /showcase/api route with interactive API explorer

Sources

← Back to Blueprint