TanStack Start skill for Codex
Server functions that validate, loaders that stay portable. Codex checks the installed Start version against known API renames before writing code, then keeps database and secret access inside validated server functions guarded by middleware. Loaders call those functions so client navigations work, webhooks become server routes with signature checks, and the build output is grepped to confirm server-only code never reaches the browser.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- tanstack-start Installs to ~/.agents/skills/tanstack-start/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install tanstack-start The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project tanstack-start Installs to .agents/skills/tanstack-start/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/tanstack-start.zipunzip -o tanstack-start.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) tanstack-start PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $tanstack-start, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Builds full-stack React apps on TanStack Start — Vite plugin setup, the __root document, server functions with createServerFn and input validation, request and function middleware, server routes for HTTP endpoints, isomorphic loaders that call server functions, selective SSR, environment variables, and hosting targets. Use when creating or extending a TanStack Start app, writing server functions or API endpoints, when secrets or database code leak into the client bundle, when a loader fails on client navigation, or when upgrading Start across beta and RC API renames.
Use it when
- Creating or extending a TanStack Start application
- Writing server functions, middleware or HTTP endpoints in Start
- Server-only code or secrets appear in the client bundle
- Upgrading across Start beta and RC API changes
Use something else when
- Only client-side routing and search params are involved: use tanstack-router
- Caching and invalidation of fetched data on the client: use tanstack-query
How to check the result
The build passes with no server-only identifiers in client output, and protected server functions reject calls without a session or with invalid input.
The mistake it prevents
Querying the database directly inside a route loader, which works on first load but runs in the browser on client navigation.
Done well means: server-only code runs only inside server functions, server routes or server-only helpers; loaders stay isomorphic and reach the server through server functions; every server function validates its input and checks auth in middleware; and the code uses the API names of the installed Start version, not a blog post’s.
Know the version first
Start moved fast through beta and RC. Before writing code, read package.json
(@tanstack/react-start, @tanstack/react-router) and match these renames:
| Older code | Current code |
|---|---|
@tanstack/start, Vinxi, app.config.ts | @tanstack/react-start, Vite plugin in vite.config.ts |
.validator(...) on createServerFn | .inputValidator(...) |
createAPIFileRoute / createServerFileRoute | server: { handlers: { GET, POST } } on createFileRoute |
export function createRouter() in router.tsx | export function getRouter() |
serverOnly(fn) / clientOnly(fn) | createServerOnlyFn(fn) / createClientOnlyFn(fn) |
If the project is on an older line, either keep its idioms consistently or upgrade in a
dedicated change. Upgrading needs network access to install packages; ask for approval
when the sandbox blocks it. Read AGENTS.md for the package manager and scripts.
Project skeleton
// vite.config.ts
import { defineConfig } from "vite";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";
export default defineConfig({
plugins: [tanstackStart(), viteReact()], // Start's plugin must come before React's
});
// src/routes/__root.tsx
import { createRootRouteWithContext, HeadContent, Outlet, Scripts } from "@tanstack/react-router";
export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
head: () => ({ meta: [{ charSet: "utf-8" }, { title: "Acme" }] }),
component: () => (
<html lang="en">
<head><HeadContent /></head>
<body>
<Outlet />
<Scripts />
</body>
</html>
),
});
src/router.tsx exports getRouter(), which creates the router with routeTree and
context. Routing itself (search params, loaders, links) follows the tanstack-router
skill.
Server functions
// src/features/projects/server.ts
import { createServerFn } from "@tanstack/react-start";
import { notFound } from "@tanstack/react-router";
import { z } from "zod";
import { authMiddleware } from "@/server/auth-middleware";
import { db } from "@/server/db";
export const getProject = createServerFn({ method: "GET" })
.middleware([authMiddleware])
.inputValidator(z.object({ id: z.string().uuid() }))
.handler(async ({ data, context }) => {
const project = await db.project.findFirst({ where: { id: data.id, orgId: context.user.orgId } });
if (!project) throw notFound();
return { id: project.id, name: project.name, updatedAt: project.updatedAt.toISOString() };
});
export const renameProject = createServerFn({ method: "POST" })
.middleware([authMiddleware])
.inputValidator(z.object({ id: z.string().uuid(), name: z.string().trim().min(1).max(80) }))
.handler(async ({ data, context }) => {
await db.project.updateMany({ where: { id: data.id, orgId: context.user.orgId }, data: { name: data.name } });
return { ok: true as const };
});
- Call with
{ data }:await getProject({ data: { id } }). GETfor reads (cacheable, idempotent),POSTfor writes.- A server function is a public HTTP endpoint. Validation and auth are not optional.
- Return plain serializable data. Map ORM rows to DTOs; never return the full record.
- Import server functions statically. Dynamic
import()of a server function module defeats the compiler’s server/client split.
Loaders are isomorphic
Route loaders run on the server for the first request and in the browser on client navigations. Code inside a loader therefore ships to the client.
export const Route = createFileRoute("/projects/$projectId")({
loader: ({ params }) => getProject({ data: { id: params.projectId } }), // RPC on client nav
component: ProjectPage,
});
- Never query the database or read secrets directly in a loader; call a server function.
- With TanStack Query:
loader: ({ context, params }) => context.queryClient.ensureQueryData(projectQuery(params.projectId)), where the query’squeryFncalls the server function. - For code that must differ by environment, use
createIsomorphicFn().server(...).client(...).
Middleware
// src/server/auth-middleware.ts
import { createMiddleware } from "@tanstack/react-start";
import { getRequest } from "@tanstack/react-start/server";
export const authMiddleware = createMiddleware({ type: "function" }).server(async ({ next }) => {
const user = await getUserFromSession(getRequest());
if (!user) throw new Error("Unauthorized"); // or throw redirect({ to: "/login" })
return next({ context: { user } });
});
| Type | Runs for | Use for |
|---|---|---|
createMiddleware({ type: "function" }) | server function calls | auth, input logging, adding context; .client() can add headers |
createMiddleware() (request, default) | every server request, including SSR and server routes | security headers, request logging, rate limiting |
Register app-wide middleware in src/start.ts with createStart(() => ({ requestMiddleware: [...], functionMiddleware: [...] })).
Server routes (HTTP endpoints)
// src/routes/api/webhooks/billing.ts
export const Route = createFileRoute("/api/webhooks/billing")({
server: {
handlers: {
POST: async ({ request }) => {
const raw = await request.text();
if (!verifySignature(raw, request.headers.get("x-signature"))) {
return new Response("invalid signature", { status: 401 });
}
await enqueueBillingEvent(JSON.parse(raw));
return new Response(null, { status: 204 });
},
},
},
});
Use server routes for webhooks, third-party callers, file downloads and anything not called from your own UI. Use server functions for your UI’s reads and writes.
Rendering and environment
- Selective SSR per route:
ssr: true(default),"data-only"(run loader on server, render component on client) orfalse(client only, for browser-dependent widgets). - Environment variables: only
VITE_-prefixed values are exposed viaimport.meta.envin client code. Read secrets withprocess.envinside server functions, server routes orcreateServerOnlyFnhelpers. - Hosting: the Vite build targets Node by default; providers such as Netlify, Vercel and Cloudflare have dedicated plugins or presets. Follow the hosting guide for the installed version rather than copying config from another project.
Gotchas
- A helper that imports the DB client, used by both a server function and a component,
drags the DB client toward the client bundle. Keep server modules under
src/server/and import them only from server functions and server routes. - Server function return values must be serializable;
Dateand other rich types depend on the serializer of your version. Return ISO strings when in doubt. - Cookie-authenticated POST server functions need CSRF protection:
SameSite=Lax(or stricter) session cookies plus an origin check in request middleware. - Throwing a plain
Errorin a server function surfaces as an error on the client; for expected failures, return a typed result instead. - After renaming a route file, the generated
routeTree.gen.tsupdates on the next dev or build run; type errors before that are stale.
Verification
-
vite buildpasses; grep the client output for server-only identifiers (grep -r "DATABASE_URL\|prisma" dist/client .output/public 2>/dev/nulladjusted to the build output folder) and expect no matches. -
tsc --noEmitpasses. - Load each touched route directly (SSR) and through client navigation; both paths return the same data.
- Call a protected server function without a session (from tests or curl against the dev server) and confirm rejection; send invalid input and confirm a validation error.
Output
List server functions (method, input schema, middleware, returned shape), server routes with their auth or signature checks, and routes with non-default SSR modes.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "TanStack Start"
short_description: "Server functions that validate, loaders that stay portable"
default_prompt: "$tanstack-start Add a getProject server function with Zod validation and an auth middleware" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.