server routes & middleware

[check: …]
📖 Read the full guide — Server Routes vs Server Functions, the per-request middleware chain, and the current vs deprecated file conventions. This page is the interactive companion.

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.

Server Route: returns a raw Response for webhooks / 3rd-party / JSON APIs Server Function: typed { data, error } RPC Middleware: auth → logging → handler (every request)

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).

Press "Send request" to watch the request flow through the chain.

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 }.

Server Route
clientPOST /api/webhook
mw · authauthMiddleware → context.session
mw · loggingloggingMiddleware → next()
server · handlerPOST → new Response(...)
clientResponse { status, headers, body }
Server Function
clientprocessEvent({ data })
mw · authauthMiddleware → context.session
mw · loggingloggingMiddleware → next()
server · validator+handler.validator → .handler → typed
client{ data, error } (typed)

Comparison — which surface for what

This bundle is HOW TanStack Start does the OTHER server surfaces: 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).