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.

Endpoints are server-only

A server.ts never enters the client bundle — it is as server-only as anything named *.server.ts, and kit enforces it the same way. Importing one from client code is an error in dev and on build:

src/routes/api/issues/server.ts is a route endpoint and cannot be imported by client code.

  src/routes/api/issues/server.ts
  imported by src/lib/features/issues/create-issue-dialog.ts as "@/routes/api/issues/server"
    ← src/routes/(dashboard)/layout.ts:3 imports { CreateIssueDialog }
    ← $implement/router
    ← .implement/entry-client.ts

The chain names the import that pulled it in, because the file that broke the rule is rarely the file you were editing.

The one that catches people is a validation schema: the endpoint declares it, and a form on the client wants the same one. Put it in a module both sides import, and let the endpoint import it too.

// src/lib/issues/schema.ts — shared, no server imports
export const NewIssueSchema = z.object({ title: z.string().min(1) });
// src/routes/api/issues/server.ts
import { db } from "@/lib/db.server";
import { NewIssueSchema } from "@/lib/issues/schema";

export const POST = handler({ body: NewIssueSchema, handle: ({ body }) => db.issues.create(body) });

Types are exempt, as always — import type { … } is erased before the module graph sees it, so a client file may read an endpoint's types freely. That is how the generated client is typed.

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.

Streaming

A handler's Response reaches the client untouched, so a body that is a ReadableStream stays one: kit never buffers it, and neither does the hooks.server.ts around it. An endpoint can answer a request now and keep writing to it for as long as it likes.

The long-lived case with a format of its own is server-sent events, and sse builds one. It is one-way — when you need both directions of one connection, reach for a WebSocket instead:

// src/routes/api/inbox/stream/server.ts
import { handler, sse } from "./$types";
import { watchInbox } from "@/lib/inbox.server";

export const GET = handler({
	handle: ({ locals }) =>
		sse<Notification>(async function* (signal) {
			for await (const notification of watchInbox(locals.user.id, signal)) {
				yield { event: "notification", data: notification };
			}
		}),
});

Each yield is one frame. data is the payload — serialized as JSON, and typed — and event, id, and retry are the format's own fields, all optional.

The generated client reads it back as the events themselves rather than as text, so a stream is one of the few Responses that still says what a caller receives:

const { data, error } = await api.GET("/api/inbox/stream");
if (error !== undefined) return;
for await (const { data: notification } of data) show(notification);

The call settles as soon as the response headers arrive — the frames are still being written — and breaking out of the loop, or aborting the call's signal, closes the connection. A browser's own EventSource reads the same URL if you would rather have its automatic reconnection.

Ending one

A stream ends when its source does, when the client goes away, or when a signal you passed aborts. All three return the iterator, so a generator's finally runs and whatever the stream was holding gets let go:

sse<Tick>(async function* (signal) {
	const subscription = await subscribe();
	try {
		for await (const tick of subscription.ticks(signal)) yield { data: tick };
	} finally {
		await subscription.close();
	}
});

That signal argument is the one thing worth taking care over. Returning a generator interrupts it at a yield and nowhere else, so a source parked on a promise that never settles is never reached — wait under the signal instead, and the disconnect is what wakes you.

OptionDefault
keepAlive15000milliseconds between comment frames, or false for none
signal—ends the stream when it aborts — a shutdown, a deadline of your own
status200and any other ResponseInit field, headers included

The keep-alive is there because an idle connection is one a proxy eventually closes. Kit sends a comment frame on that interval, which every client discards and every proxy counts as traffic.

Where a stream can live

An open connection is a resource on whatever is holding it, and hosts differ on how long they will:

HostA long-lived response
dev, and adapter-nodeas long as you like — a proxy in front may have an idle timeout of its own
adapter-cloudflareyes; waiting costs no CPU time, and a worker is billed for what it uses
adapter-vercelyes, until maxDuration — the function is stopped at the limit, mid-stream
adapter-staticno: nothing is running to hold one

A socket route reads slightly differently. Vercel will hold a streaming response open, but there is no upgrade path into a serverless function — so that adapter and the static one both fail the build on a socket route rather than deploying one that answers with a 404.

A streaming endpoint must also never be prerendered — a file is a body with an end, and a live stream has none. With a server that is already the default; a static build prerenders GET endpoints, so say so:

export const prerender = false;

The build says the same thing if you forget, rather than hanging on a response that was never going to finish.

Cross-origin requests

Kit adds no access-control-* headers to anything, and never has. An endpoint is reachable from any origin — a browser will send the request — and whether the page that sent it may read the response is decided by the headers the endpoint sets for itself. So a route meant to be read cross-origin says so, in the response and in the preflight the browser sends ahead of it:

// src/routes/health/server.ts
import { handler, json } from "./$types";
import { broker } from "@/lib/server/broker";

const cors = {
	"access-control-allow-origin": "*",
	"access-control-allow-headers": "content-type",
};

export const GET = handler({ handle: () => json(broker.stats(), { headers: cors }) });

export const OPTIONS = handler({
	handle: () => new Response(null, { status: 204, headers: cors }),
});

That is the whole story for GET, and it is deliberate: a framework that guesses a policy is a framework that has to be argued out of one. Sharing the headers across routes is a const in @/lib, or a hooks.server.ts that sets them on everything under a prefix:

// src/hooks.server.ts
export const handle: Handle = ({ event, resolve }) => {
	if (event.url.pathname.startsWith("/api/public/")) {
		event.setHeaders({ "access-control-allow-origin": "*" });
	}
	return resolve(event);
};

The one thing kit does check

A cross-site form submission that mutates is rejected with a 403 before hooks run: a POST, PUT, PATCH, or DELETE carrying application/x-www-form-urlencoded, multipart/form-data, or text/plain, from an origin that is not yours. Those are the content types a <form> on someone else's page can send at your app with no preflight and no opt-in from you — everything else is either safe or already gated by the browser's own preflight, which your endpoint answers or doesn't.

Nothing else is affected. A cross-origin GET is untouched, and so is a POST of application/json — a page on another origin cannot send one without a preflight your app has to allow first.

A request carrying no Origin header at all counts as cross-site, which is worth knowing for a non-browser client: fetch(url, { method: "POST", body: "…" }) with no content-type sends text/plain, so a script posting that way gets the 403 too. Send application/json — or name the origins allowed to post forms:

// vite.config.ts
kit({ csrf: { trustedOrigins: ["https://admin.example.com"] } });

csrf: { checkOrigin: false } turns the check off entirely, for an app that is only ever an API and does its own thing about it.

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.