implement
Server Routes

Server Routes

server.ts endpoints serve raw responses — JSON, markdown, anything.

Not every URL is a page. A server.ts in a route directory is an endpoint: it exports a handler per HTTP method and returns a standard Response.

// src/routes/api/status/server.ts
import type { RequestEvent } from "./$types";

export function GET(): Response {
	return Response.json({ ok: true });
}

export async function POST({ request }: RequestEvent): Promise<Response> {
	const body = await request.json();
	// ...
	return new Response(null, { status: 204 });
}

Handlers receive a RequestEvent — the web-standard request, the route's params as plain strings, the url, and the locals hooks.server.ts set for this request — typed by the generated ./$types. A directory serves a page or an endpoint, never both, and requests with a method the module doesn't export get a 405.

NOTE

That is the whole contract, and everything below applies to it unchanged. When you want the edges typed as well — a validated body, a typed result, and a generated client for every caller — wrap the handler in handler(). It returns a plain handler, so nothing on this page changes.

Extension routes

A directory named .md (or any .<ext>) holding a server.ts serves its parent's path with the extension appended. Params still bind from the parent pattern:

src/routes
	docs
		.md
			server.ts       → /docs.md
		[...slug]
			.md
				server.ts     → /docs/anything/below.md
			page.ts         → /docs/anything/below
// src/routes/docs/[...slug]/.md/server.ts
import type { RequestEvent } from "./$types";

export function GET({ params }: RequestEvent): Response {
	return new Response(markdownFor(params.slug), {
		headers: { "content-type": "text/markdown; charset=utf-8" },
	});
}

This is how a page can have a machine-readable twin at the same address. This site dogfoods it: every docs page serves its plain markdown at its own URL plus .md — the Copy Page button above is fetching this page's markdown.

It also negotiates: a server hook redirects any request for a docs page that asks for markdown — Accept: text/markdown — to that page's twin, so a reader that wants the source doesn't have to know the convention. Browsers never send that header, so nothing about the page changes for them.

On build

The prerender renders every GET endpoint into a real file in dist/, so the built site serves them statically:

  • An endpoint without params becomes one file at its path.
  • An extension endpoint over params derives its paths from the pages that were prerendered: every prerendered /docs/foo gets a /docs/foo.md next to it.
  • A param endpoint without an extension has no way to enumerate its paths, so it's skipped with a warning — it works in dev, but you'll need a real server to ship it.

That last point generalizes to how the app is deployed. With no adapter the built site is static: GET endpoints survive as files, and POST and friends only exist while the dev server is running. Add an adapter and every method ships — the endpoint runs per request, the way it does in dev:

// vite.config.ts
import adapter from "@implementjs/adapter-node";

kit({ adapter: adapter() });

With a server behind them, GET endpoints stop being prerendered by default too — the server can answer them with fresher data than the build could. export const prerender = true from the server.ts puts one back in the build as a file.

In dev

The dev server dispatches matching requests to your endpoint modules before falling through to page routing, with Vite transforms applied — endpoints import your app code, aliases and all, and edits apply on the next request. Endpoint requests go through hooks.server.ts like any other, in dev and on build.