Prerendered at build time

How it’s built

A short tour of every TanStack Start feature this app uses, with the code that does it. Each section links to the live example and its source file.

01

Server functions

createServerFn defines a function that always runs on the server but can be called from anywhere — loaders, components, event handlers. Input is validated with zod, and the return type flows straight to the caller. In the client bundle the handler is replaced with a fetch.

src/server/departures.functions.ts
export const createDeparture = createServerFn({ method: 'POST' })
  .validator(newDepartureSchema)
  .handler(async ({ data }) => {
    // … moderation and rate limiting
    const visitorId = await ensureVisitorId()
    const [row] = await getDb()
      .insert(schema.departures)
      .values({ ...data, platform, region, ownerId: visitorId })
      .returning()
    return toDeparture(row, visitorId)
  })

02

Type-safe routing & search params

Routes are files. Path params and search params are parsed and validated, so /board?dest=Tokyo&page=2 is typed everywhere it is read or linked to. A typo in a route path or a missing param is a compile error.

src/routes/board.index.tsx
const searchSchema = z.object({
  dest: destinationSchema.optional().catch(undefined),
  page: z.number().int().min(1).default(1).catch(1),
})

export const Route = createFileRoute('/board/')({
  validateSearch: searchSchema,
  loaderDeps: ({ search }) => search,
  loader: ({ context: { queryClient }, deps }) =>
    queryClient.query(departuresQuery(deps)),
})

03

TanStack Query with SSR

Loaders prefetch queries on the server; setupRouterSsrQueryIntegration dehydrates them into the HTML stream and hydrates them in the browser. A non-zero staleTime stops the browser from immediately refetching what the server just sent. Components read with useSuspenseQuery, and posting uses an optimistic mutation that rolls back on error.

src/router.tsx
export function getRouter() {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { staleTime: 15_000 } },
  })
  const router = createRouter({ routeTree, context: { queryClient } })
  setupRouterSsrQueryIntegration({ router, queryClient })
  return router
}

04

Streaming SSR

Return a promise from a loader without awaiting it. On the first page load, Start sends the page right away and streams each result into the same HTTP response when it resolves; <Await> fills in each slow panel. On client-side navigation, each promise resolves as its own request.

src/routes/stats.tsx
loader: async () => ({
  hourly: getHourlyActivity(),        // streamed later
  summary: await getStatsSummary(),   // in the first flush
}),

<Suspense fallback={<ChartSkeleton />}>
  <Await promise={hourly}>{(data) => <HourlyChart data={data} />}</Await>
</Suspense>

05

Selective SSR

Each route chooses how it renders with its ssr option: true (the default), 'data-only' (loader on the server, component in the browser), or false (client only). The kiosk board needs the window size and the Fullscreen API, so it is data-only.

src/routes/board.live.tsx
export const Route = createFileRoute('/board/live')({
  ssr: 'data-only',
  loader: ({ context: { queryClient } }) =>
    queryClient.query(departuresQuery(KIOSK)),
  component: Kiosk,
})

06

Server routes

Add a server.handlers object to a route file and it becomes a raw HTTP endpoint — any method, any Response. This app exposes a public JSON API with cache headers (ready for Railway’s CDN), and the health check Railway uses to gate deploys.

src/routes/api/health.ts
export const Route = createFileRoute('/api/health')({
  server: {
    handlers: {
      GET: async () => {
        // … returns 503 if the database is unreachable
        await getDb().execute(sql`select 1`)
        return Response.json({ status: 'ok' })
      },
    },
  },
})

07

Middleware

src/start.ts registers global middleware. Request middleware wraps every page render, server route and server function request — here it adds a Server-Timing header you can see in devtools. Function middleware wraps every server function call, including direct calls during SSR. Exporting a startInstance replaces Start’s default CSRF protection, so it’s added explicitly.

src/start.ts
const csrfMiddleware = createCsrfMiddleware({
  filter: (ctx) => ctx.handlerType === 'serverFn',
})

export const startInstance = createStart(() => ({
  requestMiddleware: [csrfMiddleware, requestTimingMiddleware],
  functionMiddleware: [serverFnLogMiddleware],
}))

08

Sessions & server-only code

useSession stores an encrypted cookie, here just an anonymous visitor id so you can delete your own posts. Files named *.server.ts are blocked from the client bundle by Start’s import protection (the build fails if one leaks), and createServerOnlyFn throws if called in the browser.

src/server/session.server.ts
export function useVisitorSession() {
  return useSession<{ visitorId?: string }>({
    name: 'departures-session',
    password: process.env.SESSION_SECRET!, // 32+ characters
    cookie: {
      httpOnly: true,
      sameSite: 'lax',
      secure: process.env.NODE_ENV === 'production',
    },
  })
}

10

Static prerendering

This page has no request-time data, so it is rendered to HTML during vite build and served as a static file. Everything else is rendered per request by the same Node server. It uses Nitro’s prerenderer: Start’s own prerender option currently writes its HTML after Nitro has listed its static files, so Nitro wouldn’t serve it (TanStack/router#7473).

vite.config.ts
nitro({
  prerender: { routes: ['/features'], crawlLinks: false },
})

And on Railway

Postgres on the private network

DATABASE_URL points at Postgres over railway.internal, Railway’s private network: lower latency, and no egress charges.

Migrations before traffic

Every deploy runs node scripts/migrate.mjs as a pre-deploy command, between build and deploy, so the new version only starts once migrations succeed. It’s declared in .railway/railway.ts.

Health-checked deploys

New deployments must pass /api/health before Railway switches traffic, so a deploy that can’t reach its database never replaces a working one.

A real, long-running server

Connection pools, in-memory rate limits and streaming responses work the way they do on your laptop. Requests can keep streaming for up to 15 minutes.