3 min read
On this page

Server Load & API Routes

Overview

SvelteKit provides two server-side mechanisms for handling requests: +page.server.ts for loading data that feeds into pages, and +server.ts for standalone API endpoints. Both run exclusively on the server. The distinction is about intent: page server load returns data for rendering, while API routes return responses for any client.

+server.ts for API Endpoints

A +server.ts file exports functions named after HTTP methods. Each function receives a RequestEvent and returns a Response.

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

export const GET: RequestHandler = async ({ url }) => {
  const limit = parseInt(url.searchParams.get('limit') ?? '10');
  const offset = parseInt(url.searchParams.get('offset') ?? '0');

  const posts = await db.post.findMany({
    take: limit,
    skip: offset,
    orderBy: { createdAt: 'desc' }
  });

  return json(posts);
};

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

  const body = await request.json();
  const post = await db.post.create({
    data: {
      title: body.title,
      content: body.content,
      authorId: locals.user.id
    }
  });

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

All HTTP Methods

// src/routes/api/posts/[id]/+server.ts
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/database';

export const GET: RequestHandler = async ({ params }) => {
  const post = await db.post.findUnique({
    where: { id: parseInt(params.id) }
  });

  if (!post) {
    error(404, 'Post not found');
  }

  return json(post);
};

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

  const body = await request.json();
  const post = await db.post.update({
    where: { id: parseInt(params.id) },
    data: {
      title: body.title,
      content: body.content
    }
  });

  return json(post);
};

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

  await db.post.delete({
    where: { id: parseInt(params.id) }
  });

  return new Response(null, { status: 204 });
};

The RequestHandler Type

Every exported handler is typed as RequestHandler. The event object provides everything you need to process the request.

import type { RequestHandler } from './$types';

export const POST: RequestHandler = async ({
  request,    // the raw Request object
  params,     // dynamic route params
  url,        // URL object with searchParams
  locals,     // data from hooks (auth, etc.)
  cookies,    // read/set cookies
  fetch,      // server-side fetch with cookie forwarding
  getClientAddress // client IP address
}) => {
  // handle the request
  return new Response('OK');
};

Returning JSON

The json() helper creates a JSON response with the correct Content-Type header.

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

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

// With status code
return json({ error: 'Not found' }, { status: 404 });

// With custom headers
return json(data, {
  headers: {
    'Cache-Control': 'max-age=3600'
  }
});

Streaming Responses

API routes can stream data to the client using ReadableStream.

// src/routes/api/stream/+server.ts
import type { RequestHandler } from './$types';

export const GET: RequestHandler = async () => {
  const stream = new ReadableStream({
    async start(controller) {
      for (let i = 0; i < 10; i++) {
        const data = JSON.stringify({ count: i }) + '\n';
        controller.enqueue(new TextEncoder().encode(data));
        await new Promise((r) => setTimeout(r, 500));
      }
      controller.close();
    }
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache'
    }
  });
};

Redirects

Use the redirect function to send the client to a different URL.

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

export const POST: RequestHandler = async ({ request, cookies }) => {
  const form = await request.formData();
  const token = await authenticate(
    form.get('email') as string,
    form.get('password') as string
  );

  if (!token) {
    return json({ error: 'Invalid credentials' }, { status: 401 });
  }

  cookies.set('session', token, {
    path: '/',
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 7
  });

  redirect(303, '/dashboard');
};

Sharing Database Code Between Load Functions & API Routes

Both +page.server.ts and +server.ts run on the server. They can import the same database modules, service functions, and utilities.

// src/lib/server/posts.ts
import { db } from '$lib/server/database';

export async function getPublishedPosts(limit: number, offset: number) {
  return db.post.findMany({
    where: { published: true },
    take: limit,
    skip: offset,
    orderBy: { createdAt: 'desc' },
    include: { author: { select: { name: true } } }
  });
}

export async function getPostBySlug(slug: string) {
  return db.post.findUnique({
    where: { slug },
    include: { author: { select: { name: true } } }
  });
}
// src/routes/blog/+page.server.ts
import type { PageServerLoad } from './$types';
import { getPublishedPosts } from '$lib/server/posts';

export const load: PageServerLoad = async () => {
  const posts = await getPublishedPosts(20, 0);
  return { posts };
};
// src/routes/api/posts/+server.ts
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getPublishedPosts } from '$lib/server/posts';

export const GET: RequestHandler = async ({ url }) => {
  const limit = parseInt(url.searchParams.get('limit') ?? '20');
  const offset = parseInt(url.searchParams.get('offset') ?? '0');
  const posts = await getPublishedPosts(limit, offset);
  return json(posts);
};

This pattern keeps your data access logic in one place. The page load and the API endpoint use the same function, so they always return consistent data.

+page.server.ts vs +server.ts: When to Use Each

This is one of the most common sources of confusion in SvelteKit.

+page.server.ts                          +server.ts
─────────────────────────────────────    ─────────────────────────────────────
Returns data for a +page.svelte          Returns HTTP responses (JSON, etc.)
Data is typed and passed as props        Data is a raw Response object
Only handles GET (via load function)     Handles GET, POST, PUT, DELETE, etc.
Participates in SvelteKit navigation     Independent of SvelteKit navigation
Has form actions support                 No form actions
Used when you have a page to render      Used when you need a standalone API

Use +page.server.ts When

You have a page that needs server-side data. The load function feeds data directly into the component.

// src/routes/dashboard/+page.server.ts
// This data powers the dashboard page
export const load: PageServerLoad = async ({ locals }) => {
  const stats = await getStats(locals.user.id);
  return { stats };
};

Use +server.ts When

You need an API endpoint that external clients, mobile apps, or client-side JavaScript can call directly.

// src/routes/api/stats/+server.ts
// This endpoint is called by fetch() from various clients
export const GET: RequestHandler = async ({ locals }) => {
  const stats = await getStats(locals.user.id);
  return json(stats);
};

The Practical Rule

If the data ends up in a +page.svelte component, use +page.server.ts. If the data is consumed by fetch() calls from JavaScript (whether your own frontend code, a mobile app, or a third party), use +server.ts.

User visits /dashboard
  → +page.server.ts load runs
  → data flows to +page.svelte

JavaScript calls fetch('/api/stats')
  → +server.ts GET handler runs
  → JSON response sent to caller

Form submits to /dashboard
  → +page.server.ts actions run
  → result flows back to +page.svelte

Having Both at the Same Route

You can have both +page.server.ts and +server.ts at the same route level. The page server load handles navigations and the server file handles programmatic requests.

// src/routes/todos/+page.server.ts
export const load: PageServerLoad = async ({ locals }) => {
  const todos = await db.todo.findMany({
    where: { userId: locals.user.id }
  });
  return { todos };
};
// src/routes/todos/+server.ts
export const POST: RequestHandler = async ({ request, locals }) => {
  const { text } = await request.json();
  const todo = await db.todo.create({
    data: { text, userId: locals.user.id }
  });
  return json(todo, { status: 201 });
};

The page load fetches todos for the initial render. The POST endpoint creates new todos from client-side fetch calls. They coexist cleanly.

Common Pitfalls

  • Building REST APIs when page load is sufficient: If your data only feeds a page component, you do not need a separate API route. +page.server.ts is simpler and integrates with SvelteKit's type system, navigation, and caching.
  • Forgetting to handle errors in API routes: Unlike load functions where SvelteKit catches errors and renders +error.svelte, unhandled errors in +server.ts return a generic 500. Always use try/catch or the error() helper.
  • Not validating request bodies: Always validate the shape and types of incoming JSON or form data. Never trust request.json() to return what you expect.
  • Using +server.ts for form submissions: SvelteKit form actions in +page.server.ts provide better progressive enhancement. Use +server.ts for programmatic API calls, not form handling.
  • Exposing internal data in API responses: API routes return raw JSON visible to anyone with the URL. Be deliberate about which fields you include. Use select in database queries to limit exposed data.

Key Takeaways

  • +server.ts creates API endpoints that handle any HTTP method. Export named functions (GET, POST, PUT, DELETE) that return Response objects.
  • Use json() for JSON responses, redirect() for redirects, and error() for error responses.
  • Extract shared data logic into $lib/server/ functions that both page loads and API routes can import. This keeps behavior consistent across both interfaces.
  • Use +page.server.ts when data feeds a page component. Use +server.ts when you need a standalone API endpoint for programmatic access.
  • Both files can coexist at the same route. This is common for pages that also need a programmatic API for dynamic interactions.