TanStack Start x Cloudflare Pages — A Complete Record of Pitfalls and Solutions
Go deeper on this topic
TanStack Start × Cloudflare
Full-stack setup with TanStack Start and Cloudflare Pages, plus Tailwind v4 migration
Article 2 of 4 in this series.
Next reads
What Broke in the Tailwind CSS v4 Migration — @custom-variant and Dark Mode Implementation Notes
Breaking changes encountered migrating from Tailwind CSS v3 to v4, and implementation patterns for dark/light mode using @custom-variant.
IndexNow x Cloudflare Pages — Instant URL submissions from sitemap.xml
A practical workflow: treat sitemap.xml as the source of truth, then submit all URLs to IndexNow right after a Cloudflare Pages deploy to shorten discovery time for new content.
Why I Didn't Choose Next.js — The Decision Criteria for Picking TanStack Start
A record of the technical decision to choose TanStack Start + Cloudflare Pages over Next.js for a portfolio hosting 203 interactive components.
Use the topic hub as a map, then continue to the next article in the series. New here? Start at the home hub →
Introduction
This site runs on TanStack Start v1 + Cloudflare Pages + React 19. TanStack Start v1, released in February 2026, struck me as a very well-designed Vite-based full-stack framework. Combined with Cloudflare Pages' edge runtime, you get globally fast SSR. In theory.
Reality is not so kind. The combination of "new framework x new hosting" was literally a minefield. What the documentation says isn't enough to get it working. There's no information on StackOverflow either. I read through GitHub Issues one by one, traced source code, and iterated through trial and error.
This article is a chronological record of the five landmines I stepped on and the workaround for each. I hope anyone choosing the same stack won't fall into the same holes.
Landmine 1: Vite Plugin Ordering
This was the first landmine. I wrote the Vite config and ran bun run build, and got this error:
Error: server-entry not foundIt made no sense. The entry file definitely existed. The path was correct. No matter how many times I checked, nothing was wrong.
After about 3 hours of fighting, I finally found the cause: Vite plugin registration order.
Wrong (doesn't work)
// vite.config.ts — NG
export default defineConfig({
plugins: [
tailwindcss(),
tsConfigPaths({ projects: ['./tsconfig.json'] }),
tanstackStart(), // ← before cloudflare
cloudflare({ viteEnvironment: { name: 'ssr' } }),
react(),
],
})Correct (works)
// vite.config.ts — OK
import { cloudflare } from '@cloudflare/vite-plugin'
import tailwindcss from '@tailwindcss/vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import tsConfigPaths from 'vite-tsconfig-paths'
export default defineConfig({
server: {
port: 3000,
},
plugins: [
tailwindcss(),
tsConfigPaths({ projects: ['./tsconfig.json'] }),
cloudflare({ viteEnvironment: { name: 'ssr' } }),
tanstackStart(),
react(),
],
})The correct order is tailwindcss -> tsConfigPaths -> cloudflare -> tanstackStart -> react.
The crux is that cloudflare must come before tanstackStart. The Cloudflare Vite plugin generates the SSR environment's entry point. The TanStack Start plugin references that entry point to build server-side routing. If the order is reversed, TanStack Start initializes when the entry point it needs doesn't yet exist, resulting in server-entry not found.
Vite plugins execute in array order. This is a Vite fundamental, but "which plugins to put in which order" was documented nowhere. Not in TanStack Start's docs, not in Cloudflare's docs.
Lesson: Think of Vite plugin ordering as a "dependency graph." If a downstream plugin needs the output of an upstream plugin, place the upstream one first.
Landmine 2: ServerFn Doesn't Work
One of TanStack Start's flagship features is ServerFn (server functions). An RPC-style API that lets client components directly call server-side logic. The equivalent of Next.js's Server Actions.
Naturally, I tried implementing data fetching with it. It worked fine locally. Development was comfortable with bun run dev.
The moment I deployed to Cloudflare Pages, everything broke.
Error: ServerFn handler not found in worker environmentInvestigation revealed this to be a known bug where ServerFn doesn't work correctly in Cloudflare Pages' SSR environment (as of February 2026). Multiple reports existed in TanStack Start's GitHub Issues, but no clear fix timeline was given.
Workaround: Migrating to a Static Data Layer
If ServerFn doesn't work, there's no choice but to abandon the mechanism for fetching data server-side.
I switched to a strategy of placing all data as static TypeScript constants in the src/data/ directory.
src/data/
├── profile.ts # Profile information
├── works.ts # Portfolio data
├── skills.ts # Skill set
├── social.ts # Social links
└── blog/
└── index.ts # Blog post listEach file follows this format:
// src/data/works.ts
export type Work = {
slug: string
title: string
description: string
tags: string[]
// ...
}
export const works: Work[] = [
{
slug: 'fluid-simulation',
title: 'Fluid Simulation',
description: '...',
tags: ['Canvas', 'Physics'],
},
// ...
]Blessing in Disguise
Initially, I thought of this as a "compromise accepting constraints." But in practice, this turned out to be a surprisingly good design.
No CMS needed: Content is the TypeScript files themselves. No CMS API calls needed. Type checking runs at build time, so data inconsistencies are caught before deployment.
Type-safe: Changing the
Worktype produces compile errors everywhere that type is referenced. Data-UI consistency is automatically guaranteed.Edge-optimized: Since data is included in the bundle, no external API calls are needed at runtime in Cloudflare Workers. Fast cold starts, fast responses.
Offline development: Works completely without a network. You can develop on an airplane.
Even if ServerFn starts working, I wouldn't change this design. For content with low update frequency like a portfolio site, a static data layer is the optimal solution.
Lesson: When facing framework constraints, reframing the "workaround" as an "alternative design pattern" can lead to better answers.
Landmine 3: Dark Mode FOUC
This site defaults to dark mode. User preferences are saved in localStorage and restored on the next visit. A common pattern so far.
The problem is FOUC (Flash of Unstyled Content). Server renders HTML -> browser parses HTML -> CSS is applied -> JavaScript hydrates -> theme is applied. Between "CSS is applied" and "theme is applied," there's a brief white screen flash.
For dark mode users, a flash of white light in their eyes on every page navigation is the worst possible experience.
Solution: Inline Script in Head
The theme needs to be applied before JavaScript hydration. That means placing an inline script in <head>.
// src/routes/__root.tsx
export const Route = createRootRoute({
head: () => ({
scripts: [
{
children: \`
(function() {
var t = localStorage.getItem('theme');
if (t === 'light' || (!t && !window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('light');
}
})();
\`,
},
],
// ...
}),
})This script works with the following logic:
- Retrieve the saved theme from
localStorage - If the theme is
light, or if unset and the OS is in light mode, add thelightclass to<html> - Otherwise (dark mode), do nothing (since dark is the default)
suppressHydrationWarning
On the server side, HTML is generated without the light class on <html>. On the client side, the inline script may add the light class. This mismatch triggers React's hydration warning.
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="ja" suppressHydrationWarning>
<head>
<HeadContent />
</head>
{/* ... */}
</html>
)
}Adding suppressHydrationWarning to the <html> tag suppresses this warning. This is a React-sanctioned use case. Cases where SSR/CSR mismatch is intentional, like theme switching, are exactly what this attribute is for.
Combining with Tailwind v4
Tailwind v4 uses @custom-variant for dark mode control:
/* src/styles/app.css */
@custom-variant dark (&:where(.dark, .dark *));
@custom-variant light (&:where(.light, .light *));The dark: prefix depends on the .dark class existing, and the light: prefix depends on the .light class existing. By writing default styles for dark mode and overriding with light:, no class = dark mode works.
Lesson: Patterns where UI breaks in the "gap" between SSR and client-side are common. Especially when handling user-specific state like themes and localization in SSR, early application via inline scripts is the standard approach.
Landmine 4: wrangler Requires a Full Build
A landmine hit during CI/CD pipeline optimization.
I wanted to separate build and deploy in GitHub Actions. The common pattern:
- Build job:
bun run build-> upload build artifacts - Deploy job: download artifacts ->
wrangler pages deploy
This didn't work.
Error: Missing wrangler.json configurationCause
@cloudflare/vite-plugin dynamically generates wrangler.json during the build. This config file depends on the Vite plugin context (entry points, environment variables, bindings, etc.).
When only build artifacts are passed to a separate job, the Vite plugin context is missing. wrangler.json exists within the build directory, but the file paths and config values it references can't be resolved in the deploy job's environment.
Solution: Full Rebuild in Deploy Job
# .github/workflows/deploy.yml
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: bun run build
- run: bunx wrangler pages deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}Build and deploy are performed within the same job. Artifact separation is abandoned.
Build time is on the order of tens of seconds, so there's no practical issue. However, it's worth knowing that the CI/CD best practice of "separating build and deploy" doesn't apply here.
Lesson: Files dynamically generated by Vite plugins depend on the build context. When separating build artifacts in CI/CD, you need to understand what the plugin generates.
Landmine 5: CSP and Inline Script Coexistence
Landmine 3 used an inline script to solve FOUC. Landmine 5 is about that inline script conflicting with security headers.
On Cloudflare Pages, response headers can be set via the public/_headers file. I wanted to set CSP (Content Security Policy) for XSS protection.
The Problem
If the script-src directive in CSP only allows 'self', the FOUC-prevention inline script gets blocked. On the other hand, extracting the inline script to an external file delays loading and FOUC recurs.
Furthermore, Tailwind v4 also uses inline styles in some cases, requiring 'unsafe-inline' for style-src as well.
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; ...The Difficulty of Nonce-Based Approach
Ideally, nonces (one-time tokens) should be used instead of 'unsafe-inline'. The server generates a random nonce per request and embeds it in both the CSP header and the inline script.
<!-- Ideally this -->
<script nonce="abc123">
(function() { /* FOUC prevention */ })();
</script>Content-Security-Policy: script-src 'nonce-abc123'However, Cloudflare Pages' _headers file is static. It can't generate dynamic nonces per request and embed them in headers. Cloudflare Workers could make this possible, but a configuration where Pages Functions rewrite response headers risks interfering with TanStack Start's routing.
Accepting the Tradeoff
Ultimately, I decided to accept 'unsafe-inline'.
# public/_headers
/*
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://m.media-amazon.com ...; font-src 'self'; connect-src 'self'; media-src 'self' blob:; frame-ancestors 'none'
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()'unsafe-inline' can't completely eliminate XSS risk, but it's supplemented by defense-in-depth with other headers (X-Content-Type-Options, X-Frame-Options, frame-ancestors 'none').
The nature of this as a portfolio site -- with virtually no user input surfaces and an extremely small XSS attack surface -- also factored into the decision. Security should be evaluated in context; not every site needs the same standards.
Lesson: Security and UX often trade off. Rather than pursuing perfection, accurately assessing risk and supplementing with defense-in-depth is more realistic.
Current Architecture
After navigating five landmines, the architecture settled on the following.
Data Layer
All content is placed as static TypeScript constants in src/data/. Type definitions are schemas, export const are records. No CMS, no API, type-checked at build time.
Rendering
Edge SSR on Cloudflare Pages. The head() function generates meta tags, OGP, and JSON-LD. SEO information is defined in a type-safe manner per route.
export const Route = createFileRoute('/blog/$slug')({
head: ({ params }) => {
const post = getBlogPost(params.slug)
return {
meta: [
{ title: post?.title },
{ name: 'description', content: post?.description },
{ property: 'og:title', content: post?.title },
// ...
],
}
},
})Security and Caching
Managed centrally via public/_headers. Static assets get immutable + 1-year cache; content gets appropriate TTLs.
CI/CD
GitHub Actions with Blacksmith runners. Build and deploy execute within the same job.
Summary: Lessons from TanStack Start x Cloudflare
I wrestled with this stack for about three weeks. Looking back, there are three things to say.
1. New Framework x New Hosting = Minefield
Both TanStack Start v1 and the Cloudflare Vite plugin are excellent tools individually. But the moment you combine them, edge cases documented in neither surface in droves. Until the ecosystem matures, you need to be prepared to absorb this cost.
2. What's Not in the Documentation Matters Most
Vite plugin ordering, wrangler's dynamic generation, CSP and inline script conflicts -- none of these are in any documentation. GitHub Issues, Discord, and source code become the real documentation.
3. Accepting Constraints Leads to Simple Design
Can't use ServerFn -> static data layer. Can't generate nonces -> accept unsafe-inline. Can't separate artifacts -> build and deploy in the same job. When you treat constraints not as "problems" but as "design inputs," the result is a simple, robust architecture.
Finally, this article itself is stored as a TypeScript file in src/data/blog/. The design born from the constraint that ServerFn doesn't work is now hosting blog articles. Delivering a record of landmines through the architecture those landmines created. Rather poetic.
FAQ
- Q. What should you watch out for first when combining TanStack Start with Cloudflare Pages?
- Vite plugin registration order is the most critical. If the cloudflare plugin is not placed before tanstackStart, you get a "server-entry not found" error.
- Q. Can TanStack Start's ServerFn be used with Cloudflare Pages?
- As of February 2026, there is a known bug where it does not work correctly in SSR environments. A workaround is to place all data as static TypeScript constants in src/data/.
- Q. How do you prevent dark mode FOUC (white flash) with SSR?
- Place an inline script in head that reads the theme from localStorage and applies the HTML class before hydration. Add suppressHydrationWarning to the html element to suppress warnings.