Skip to content
ExponaBLOG
SVELTE & SVELTEKIT · DAY 023 / 100

API routes in SvelteKit: real-world lessons and examples

+server.ts endpoints.

Expona Admin7 min read

Building robust backend logic in SvelteKit doesn't require a separate server—you can use +server.ts endpoints to handle everything from API calls to webhook processing. We've learned that mastering API routes in SvelteKit: real-world lessons and examples matters because it's where your frontend meets real data, and getting the patterns right saves hours of debugging later.

What are API routes and why they matter

In SvelteKit, +server.ts files act as your API layer. Instead of spinning up Express or Fastify separately, you colocate your endpoint logic alongside your routes. A file like src/routes/api/users/+server.ts becomes an endpoint at GET /api/users. This colocation keeps related code together and lets you lean on SvelteKit's routing system for both pages and APIs.

We've seen teams struggle when they treat +server.ts like a quick escape hatch for "server code" without thinking about validation, error handling, or response shape. That's where most issues emerge. The endpoint layer is where contracts get enforced—it's not optional infrastructure.

Define handlers — Export GET, POST and other methods

Each +server.ts file exports named functions for HTTP verbs: GET, POST, PUT, DELETE, PATCH. These are your handler signatures. A basic example:

typescript

import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';

export const GET: RequestHandler = async ({ params }) => {
  const userId = params.id;
  // Fetch user from database
  const user = await db.users.findUnique({ where: { id: userId } });
  
  if (!user) {
    return json({ error: 'Not found' }, { status: 404 });
  }
  
  return json(user);
};

Notice the RequestHandler type—it keeps us honest about what we're receiving and returning. The function receives a RequestEvent object with params, url, request, cookies, and locals. In production, we've found that using these correctly prevents entire classes of bugs.

For POST, you'll often need to read the request body:

typescript

export const POST: RequestHandler = async ({ request }) => {
  const body = await request.json();
  // Process body
  return json({ created: true }, { status: 201 });
};

A pattern we use everywhere: always validate before you touch your database. Don't assume the body shape is correct.

Return JSON — json() helper for responses

SvelteKit provides a json() helper that sets the correct Content-Type header and serializes your data. It accepts a second argument for response options—status codes, headers, and cookies:

typescript

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

// Simple response
return json({ message: 'Success' });

// With status code
return json({ error: 'Invalid input' }, { status: 400 });

// With custom headers
return json(data, {
  status: 200,
  headers: { 'X-Custom-Header': 'value' }
});

In production e-commerce systems, we've built response envelopes like:

typescript

interface ApiResponse {
  success: boolean;
  data?: T;
  error?: string;
  timestamp: number;
}

export const GET: RequestHandler = async () => {
  try {
    const items = await db.items.findMany();
    return json>({
      success: true,
      data: items,
      timestamp: Date.now()
    });
  } catch (err) {
    return json>(
      {
        success: false,
        error: 'Database error',
        timestamp: Date.now()
      },
      { status: 500 }
    );
  }
};

This envelope gives your frontend a predictable shape and makes error handling consistent across your app.

Validation — Parse bodies with Zod

This is where API routes often fail in the real world. You receive arbitrary JSON and assume it's shaped correctly. Zod (or similar libraries) enforce contracts at the boundary:

typescript

import { z } from 'zod';
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';

const CreateUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(1),
  age: z.number().int().positive().optional()
});

export const POST: RequestHandler = async ({ request }) => {
  try {
    const body = await request.json();
    const validated = CreateUserSchema.parse(body);
    
    // Now 'validated' is type-safe
    const user = await db.users.create({
      data: validated
    });
    
    return json(user, { status: 201 });
  } catch (err) {
    if (err instanceof z.ZodError) {
      return json(
        { error: 'Validation failed', issues: err.issues },
        { status: 400 }
      );
    }
    return json({ error: 'Server error' }, { status: 500 });
  }
};

We've seen teams skip validation and then spend weeks chasing subtle state corruption bugs. The few extra lines up front are an insurance policy.

Use for — Webhooks, APIs and file downloads

Webhooks: Incoming webhooks from Stripe, GitHub, or other services hit your +server.ts endpoints. You validate the signature, process the event, and return 200 quickly:

typescript

import crypto from 'crypto';
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';

export const POST: RequestHandler = async ({ request }) => {
  const signature = request.headers.get('stripe-signature');
  const body = await request.text();
  
  // Verify signature
  const secret = process.env.STRIPE_WEBHOOK_SECRET;
  const hash = crypto
    .createHmac('sha256', secret)
    .update(body)
    .digest('hex');
  
  if (hash !== signature) {
    return json({ error: 'Unauthorized' }, { status: 401 });
  }
  
  const event = JSON.parse(body);
  
  // Dispatch to background job queue
  await queue.enqueue('process_webhook', event);
  
  return json({ received: true });
};

File downloads: Return binary data with appropriate headers:

typescript

import { text } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';

export const GET: RequestHandler = async () => {
  const csv = generateCSVReport();
  
  return text(csv, {
    headers: {
      'Content-Disposition': 'attachment; filename="report.csv"'
    }
  });
};

RESTful APIs: Your standard CRUD operations. A route like src/routes/api/orders/[id]/+server.ts handles /api/orders/123:

typescript

export const GET: RequestHandler = async ({ params }) => {
  const order = await db.orders.findUnique({
    where: { id: params.id }
  });
  return json(order || {}, { status: order ? 200 : 404 });
};

export const PATCH: RequestHandler = async ({ params, request }) => {
  const updates = await request.json();
  const order = await db.orders.update({
    where: { id: params.id },
    data: updates
  });
  return json(order);
};

Common mistakes

  • Trusting the request body shape: Always validate with Zod or similar. A single undefined in a critical field can cascade through your system.
  • Ignoring error boundaries: Every database call, API call, or file operation can fail. Wrap in try-catch and return sensible error responses. Don't let unhandled exceptions leak to the client.
  • Forgetting authentication: locals from hooks gives you user context, but you must check it. Don't assume someone is authenticated just because they hit your endpoint.
  • Blocking long operations: File uploads, heavy computations, or external API calls block the response. Offload to a queue (Bull, BullMQ, or a job runner) and return immediately.
  • Missing CORS headers: If your frontend is on a different domain, you need cors() middleware or explicit headers. SvelteKit handles same-origin cases automatically, but cross-origin requests need explicit allowance.
  • Not versioning your API: As your schema evolves, old clients break. Consider /api/v1/users to give yourself upgrade headroom.

Key takeaways

  • +server.ts files export named handler functions (GET, POST, etc.) for HTTP methods—no separate framework needed.
  • Use the json() helper with appropriate status codes and headers to shape responses consistently.
  • Validate all incoming data with Zod before touching your database or business logic; catch validation errors and return 400 responses.
  • Deploy webhooks, file downloads, and REST APIs through +server.ts—route colocation keeps related code together.
  • Always wrap database and external calls in try-catch; return predictable error responses rather than leaking stack traces.
  • Check authentication in your handlers via locals; don't assume users are authenticated.
  • For heavy operations, queue jobs asynchronously and return 202 Accepted to avoid blocking responses.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles