The one idea
A Server Route (formerly "API Route") is a raw HTTP handler that returns a
Response — for webhooks, 3rd-party callers, JSON/RSS/image APIs.
A Server Function is typed RPC for your own app code.
Middleware is the per-request chain that wraps BOTH —
auth, logging, context injection.
A server route is a file in src/routes/ calling
createFileRoute('/path')({ server: { handlers: { POST: async ({ request }) => new Response(...) } } }).
The handler returns a Response — you set status, headers, and body manually; it is
not type-safe RPC. Middleware is createMiddleware() from
@tanstack/react-start; the global requestMiddleware (set in
src/start.ts via createStart()) runs on every server request —
server routes, SSR, and server functions alike.
The file convention — current vs deprecated
The CURRENT endpoint convention is createFileRoute('/path')({ server: { handlers } }) — a
file in src/routes/ (the same dir as your app routes). The old
createAPIFileRoute() and the separate src/api.ts entry handler were removed
in the beta (the feature was renamed "API Routes" → "Server Routes"). routes/api/webhook.ts
maps to the URL /api/webhook — the api/ prefix is just a path segment, not a
special directory.
Side by side — a webhook server route vs a server function
Both run on the server; both are reachable by URL. The server route returns a raw
Response (Stripe/whichever webhook calls it). The server function returns typed data to
your own client. Both pass through the same global middleware first.
src/routes/api/webhook.ts Server Route
// CURRENT convention: createFileRoute + `server` property (NOT createAPIFileRoute). import { createFileRoute } from '@tanstack/react-router'; export const Route = createFileRoute('/api/webhook')({ server: { // route-level middleware runs for ALL methods on this route: middleware: [rawBodyMiddleware], handlers: { POST: async ({ request, context }) => { const event = await verifyStripeSignature( request, context.rawBody, ); await db.insertEvent(event); // returns a raw Response — status, headers, body are YOUR job: return new Response(JSON.stringify({ received: true }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); }, }, }, });
// called by a 3rd-party webhook (Stripe) — a real URL, no Start client needed: // POST https://app.example.com/api/webhook body=stripe-event // -> Response { status: 200, headers, body } (untyped to the caller)
src/server/process.event.ts Server Function
// typed RPC for YOUR app code — validated input, inferred return, no fetch. import { createServerFn } from '@tanstack/react-start'; import { z } from 'zod'; export const processEvent = createServerFn({ method: 'POST' }) .validator(z.object({ eventId: z.string().uuid(), })) .handler(async ({ data }) => { const event = await db.findEvent(data.eventId); return { processedAt: new Date(), ok: true }; });
// called from YOUR client like a local async fn — typed end-to-end: const { data, error } = await processEvent({ data: { eventId: id } }); // data.processedAt <-- typed: Date (NOT a Response — no status/headers)
The middleware — createMiddleware() + global createStart()
There is no createServerMiddleware — the current API is createMiddleware().
A request middleware (the default) runs on every server request; a
function middleware ({ type: 'function' }) adds client + validator steps
for server fns. Both are next-able — call next() to continue the chain.
// src/middleware.ts import { createMiddleware } from '@tanstack/react-start'; export const authMiddleware = createMiddleware().server( async ({ next, request }) => { const session = await getSession(request.headers); // read cookie return next({ context: { session } }); // inject into context }, ); export const loggingMiddleware = createMiddleware().server( async ({ next, request }) => { const t = Date.now(); const res = await next(); console.log(request.method, request.url, Date.now() - t + 'ms'); return res; }, );
// src/start.ts — GLOBAL request middleware runs on EVERY request // (server routes + SSR + server functions). NOT included in the default template. import { createStart, createCsrfMiddleware } from '@tanstack/react-start'; import { authMiddleware, loggingMiddleware } from './middleware'; export const startInstance = createStart(() => ({ requestMiddleware: [ createCsrfMiddleware(), // Start installs this automatically if no src/start.ts authMiddleware, // order = execution order, dependency-first loggingMiddleware, ], }));
Try it — send a request through the chain two ways
Pick a target and send the request. The global middleware chain runs identically on both
paths — that's the point: middleware is per-request, not per-surface. What differs is the
handler's return shape: a raw Response (server route) vs a typed
{ data, error } (server function).
The flow — request → middleware stack → endpoint OR server-fn
Both lanes share the green middleware nodes (they run on every request, in order).
The branch happens at the handler: a server route returns a Response; a server function
returns typed { data, error }.
Comparison — which surface for what
createFileRoute + server
for raw HTTP endpoints (webhooks/3rd-party), createMiddleware for the per-request chain that
wraps everything. Cross-refs:
🔗 server_functions (the OTHER server surface — typed RPC) ·
🔗 ssr_streaming (SSR requests also pass the global middleware) ·
🔗 astro_actions_endpoints (the same endpoint idea, Astro side).