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.
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.
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.
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.
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.
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.
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.
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.
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',
},
})
}09
Head, errors & not-found
Every route can set its own <title> and meta tags from loader data. Throwing notFound() from a loader or server function renders the nearest notFoundComponent; errors render the errorComponent.
head: ({ loaderData }) => ({
meta: loaderData
? seo({ title: `${loaderData.name} → ${loaderData.destination}` })
: seo({ title: 'Ticket not found' }),
}),
notFoundComponent: () => <NotFound>This ticket doesn’t exist.</NotFound>,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).
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.