Embracing TanStack Start for full-stack development on Cloudflare - PageZERO v2

Embracing TanStack Start for full-stack development on Cloudflare - PageZERO v2

If 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 as ad hoc API endpoints, are replaced by TanStack Start's server functions. React Router loaders fetch the data a route needs before it renders, while actions handle mutations, like form submissions that create or update data.

React Router loader:

export async function loader({ context }: Route.LoaderArgs) {
  const db = context.get(dbContext)
  return { projects: await db.select().from(projectsTable) }
}

export default function ProjectsPage({ loaderData }: Route.ComponentProps) {
  return <ProjectsList projects={loaderData.projects} />
}

TanStack Start equivalent:

import { createServerFn } from "@tanstack/react-start"
import { createFileRoute } from "@tanstack/react-router"
import { getMainDb } from "@/db/main"

export const getProjects = createServerFn({ method: "GET" }).handler(async () => {
  const db = getMainDb()
  return { projects: await db.select().from(projectsTable) }
})

export const Route = createFileRoute("/projects")({
  loader: () => getProjects(),
  component: ProjectsPage,
})

React Router action:

export async function action({ request, context }: Route.ActionArgs) {
  const db = context.get(dbContext)
  const formData = await request.formData()
  const name = formData.get("name") as string
  await db.insert(projectsTable).values({ name })
  return redirect("/projects")
}

TanStack Start equivalent:

import { createServerFn } from "@tanstack/react-start"
import { getMainDb } from "@/db/main"

export const createProject = createServerFn({ method: "POST" })
  .validator((data: { name: string }) => data)
  .handler(async ({ data }) => {
    const db = getMainDb()
    await db.insert(projectsTable).values({ name: data.name })
  })

A few concrete wins fall out of that shift:

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 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"
import { tanstackStart } from "@tanstack/react-start/plugin/vite"
import react from "@vitejs/plugin-react"
plugins: [
  cloudflare({ viteEnvironment: { name: "ssr" } }),
  tanstackStart({
    ...
  }),
  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 db:setup chain. v2 drops that ceremony: Wrangler provisions D1 databases automatically on first deploy, and migrations run through wrangler d1 migrations apply instead of drizzle-kit migrate.

A first deploy now looks like this:

  1. Log in to Cloudflare with bunx wrangler login.
  2. Reset wrangler.json for your project with bun setup:wrangler.
  3. Deploy with bun deploy:preview and bun deploy:production.
  4. Apply migrations with bun db:migrate --env preview --remote and bun db:migrate --env production --remote.

Push-to-deploy through GitHub Actions still works the same way: every merge to main ships to production and every PR gets a preview environment, with migrations applied automatically per environment via the official cloudflare/wrangler-action.

A built-in blog

v2 ships a file-based blog out of the box. Posts live as .mdx files in apps/blog/content/. Drop one in and it shows up on the blog index automatically, served at /blog/<slug> with the slug derived from the filename. No route registration step. A minimal post looks like this:

import cover from "../assets/hello-cover.jpg"

export const frontmatter = {
  title: "Hello, world",
  description: "My first post.",
  date: "2026-06-01",
  imgSrc: cover,
  author: { name: "Jane Doe" },
}

Regular **Markdown** works here, alongside any JSX you need.

Each post exports a frontmatter object (title, description, date, cover image, author) that is validated with Zod at load time, so a missing field fails loudly at build instead of quietly producing a broken page. Posts get SEO meta tags from that frontmatter automatically, code blocks are syntax-highlighted, and the index is sorted newest-first. The post you are reading right now is, fittingly, just such an MDX file.

MDX static content

The blog is really one instance of a more general capability: MDX content compiled at build time into React components. Frontmatter is written as a JS export rather than YAML, which means a content file can import local image assets processed by Vite and reference live config values directly.

The same getMdxModuleBySlug helper and MDXProvider that power the blog route work for any content area. Here is a docs route built the same way:

import { createFileRoute, notFound } from "@tanstack/react-router"
import { getMdxModuleBySlug, MDXProvider } from "@/mdx"
import { docModules, docFrontmatterSchema } from "../content"

export const Route = createFileRoute("/docs/$slug")({
  component: DocPage,
})

function DocPage() {
  const { slug } = Route.useParams()
  const mod = getMdxModuleBySlug(docModules, docFrontmatterSchema, slug)
  const DocComponent = mod?.default
  if (!DocComponent) throw notFound()

  return (
    <MDXProvider>
      <DocComponent />
    </MDXProvider>
  )
}

v2 uses this for more than blogging. Legal pages moved to MDX files, and a shared ProseArticle layout handles any read-focused page. Adding a new content area follows the same shape as the docs route above: a content folder with an index.ts globbing its .mdx files, an index and detail route, both registered in apps/routes.ts. Static prerendering and sitemap generation are scaffolded in vite.config.ts and ready to switch on.

A newsletter with double opt-in

The new newsletter app adds a complete subscription flow powered by Resend. A subscriber enters their email in the ready-made NewsletterSignup component, receives a confirmation email, and is only added to your Resend audience after they click through. A proper double opt-in that keeps your list clean and compliant.

Drop the form into any route. When Cloudflare Turnstile is configured, load the public key in the route loader and pass it through, otherwise omit the prop and bot protection stays off:

import { NewsletterSignup } from "@/newsletter/components/newsletter-signup"
<NewsletterSignup
  cloudflareTurnstilePublicKey={cloudflareTurnstilePublicKey}
/>

The details are handled for you: confirmation links are signed and expire after 30 minutes, Cloudflare Turnstile bot protection is optional, and the contact lands in a configured audience segment on confirmation. The signup form is already embedded on the home page.

Type-safe forms

Building forms by hand is tedious and easy to get subtly wrong, so v2 introduces a small @/form package that ties together TanStack Start server functions, TanStack Query mutations, and Zod validation around one shared Zod schema. Rather than pulling in a full form-state library like TanStack Form, it stays deliberately thin: native FormData, a single hook, and a schema you already have.

Here is the client side of it, a form wired up with the useFormAction hook against a server function that validates incoming data with that same schema:

import { useFormAction } from "@/form"

function ContactForm() {
  const { onSubmit, fields, isPending } = useFormAction(
    contactFormSchema,
    contactFormAction,
  )

  return (
    <form onSubmit={onSubmit} noValidate>
      <input name={fields.email.name} type="email" />
      {fields.email.errors.map((err) => (
        <p key={err}>{err}</p>
      ))}
      <button type="submit" disabled={isPending}>
        {isPending ? "Sending..." : "Send"}
      </button>
    </form>
  )
}

useFormAction wraps the server function in a TanStack Query mutation and hands back an onSubmit plus typed fields with per-field validation errors, so you never manage individual input state by hand. The schema is the single source of truth, so field names, types, and error messages stay in sync. The newsletter signup is built on exactly this primitive.

Ready for horizontally scaling D1

Cloudflare D1 database is designed for horizontal scaling: many small, independent SQLite databases rather than one giant one. v1 didn't leave room for that, everything lived behind a single DB binding at packages/db/.

v2 renames it to DB_MAIN and moves it into its own packages/db/main/ folder with its own schema and migrations. That DB_<NAME> / packages/db/<name>/ convention is now the pattern to follow: adding a second database, e.g. DB_SECONDARY, means registering the binding in wrangler.json, adding a matching packages/db/secondary/ folder with a getSecondaryDb(), and reusing the existing scripts:

DB_BINDING=DB_SECONDARY bun run db:generate
DB_BINDING=DB_SECONDARY bun run db:migrate

The starter still ships with a single DB_MAIN by default, but the seams for splitting off a second one (per tenant, per domain, per region) are already there.

Getting started

If this is your first time hearing about PageZERO, everything above (TanStack Start, the blog, the newsletter, type-safe forms, MDX content, the simplified deployment flow, and the groundwork for multiple databases) ships out of the box the moment you generate a project. One command and you can start building your app on solid foundations:

bunx pagezero@latest init

For the full picture, the documentation covers every module in PageZERO, and the source is on GitHub under MIT.