Embracing TanStack Start for full-stack development on Cloudflare - PageZERO v2 - PageZEROIf you have not run into PageZERO before, it is an open source, Cloudflare-native web app starter. You get auth, payments, permissions, email, blog, newsletter, a database layer, a tested CI/CD pipeline, and more, running on Cloudflare Workers and Cloudflare D1 database.
It requires Bun to be installed. Once you have it, start with one command:
bunx pagezero@latest init
That is the foundation. Today I want to talk about what changed in v2 , the largest release since the project began, and TanStack Start is at the center of it.
From React Router v7 to TanStack Start
PageZERO v1 was built on React Router v7. Unfortunately, the strategic picture shifted. The core React Router team is focused on Remix 3, which is no longer React-based. Community momentum is fading, and betting the web app foundation on a framework moving away from React is a risk not worth taking. That's why PageZERO v2 swaps React Router v7 for TanStack Start.
The main difference in practice is that React Router's loaders and actions, which doubled<br>as ad hoc API endpoints, are replaced by TanStack Start's server functions .<br>React Router loaders fetch the data a route needs before it renders, while actions<br>handle mutations, like form submissions that create or update data.
React Router loader :
export async function loader({ context }: Route.LoaderArgs) {<br>const db = context.get(dbContext)<br>return { projects: await db.select().from(projectsTable) }
export default function ProjectsPage({ loaderData }: Route.ComponentProps) {<br>return ProjectsList projects={loaderData.projects} />
TanStack Start equivalent:
import { createServerFn } from "@tanstack/react-start"<br>import { createFileRoute } from "@tanstack/react-router"<br>import { getMainDb } from "@/db/main"
export const getProjects = createServerFn({ method: "GET" }).handler(async () => {<br>const db = getMainDb()<br>return { projects: await db.select().from(projectsTable) }<br>})
export const Route = createFileRoute("/projects")({<br>loader: () => getProjects(),<br>component: ProjectsPage,<br>})
React Router action :
export async function action({ request, context }: Route.ActionArgs) {<br>const db = context.get(dbContext)<br>const formData = await request.formData()<br>const name = formData.get("name") as string<br>await db.insert(projectsTable).values({ name })<br>return redirect("/projects")
TanStack Start equivalent:
import { createServerFn } from "@tanstack/react-start"<br>import { getMainDb } from "@/db/main"
export const createProject = createServerFn({ method: "POST" })<br>.validator((data: { name: string }) => data)<br>.handler(async ({ data }) => {<br>const db = getMainDb()<br>await db.insert(projectsTable).values({ name: data.name })<br>})
A few concrete wins fall out of that shift:
li]:mt-2"><br>Simpler client-server communication. Server functions handle the request and response behind the scenes, so calling server code from the client feels like calling a regular function, no route or URL to define by hand.
End-to-end type safety. Return types and validated input flow straight from server function to caller, no Route.LoaderArgs or Route.ActionArgs generics to pass around.
One RPC layer for everything. rpc/ is now the single place callable server logic lives, instead of splitting it across page routes, resource routes, and ad hoc API endpoints.
Reusable by default. One server function can be called from multiple loaders, a beforeLoad guard, a form submission, or a client event handler, no more duplicating logic across a page route and a resource route just to reach it from two places.
TanStack Start and Cloudflare
TanStack Start runs on Cloudflare Workers without any adapter gymnastics (unlike Next.js). wrangler.json simply points its main entry at the framework's own server bundle:
"main": "@tanstack/react-start/server-entry"
Wrangler builds that entry, hands it to a Worker, and server-side rendering just works: every<br>route renders on the edge, close to the user.
Like React Router before it, TanStack Start itself is just a Vite plugin, tanstackStart(), dropped into vite.config.ts alongside the Cloudflare plugin:
import { cloudflare } from "@cloudflare/vite-plugin"<br>import { tanstackStart } from "@tanstack/react-start/plugin/vite"<br>import react from "@vitejs/plugin-react"
plugins: [<br>cloudflare({ viteEnvironment: { name: "ssr" } }),<br>tanstackStart({<br>...<br>}),<br>react(),
That plugin also handles static prerendering: pages you mark for it are built to static HTML at deploy time and served straight from Cloudflare's CDN via the assets binding, bypassing the Worker entirely. Everything else keeps rendering on demand through the Worker. Same codebase, same routes, and the right pages are served from the CDN without a separate static site to maintain.
A simpler deployment to Cloudflare
v1 required manual work before a first deploy: create each D1 database with wrangler d1 create, paste the ID into wrangler.json, then run a bespoke...