Solutions to common issues when using prestruct.
Build Errors
“Cannot find module ‘react-router-dom/server’”
Cause: Node’s ESM resolver requires explicit .js extension on CF Pages.
Solution:Use react-router-dom/server.js (with .js extension):
// Wrong
import { renderToString } from 'react-router-dom/server'
// Correct
import { renderToString } from 'react-router-dom/server.js'
“Module not found: Error: Can’t resolve ‘react-router-dom/server’”
Same as above - add the .js extension.
“window is not defined”
Cause: Code is accessing window, document, or localStorage during SSR render.
Solution:Guard browser-only code:
// Wrong
const [theme] = useState(localStorage.getItem('theme'))
// Correct
const [theme] = useState(() => {
if (typeof window === 'undefined') return 'light'
return localStorage.getItem('theme') || 'light'
})
Or use useEffect:
useEffect(() => {
// Safe here - only runs in browser
const saved = localStorage.getItem('theme')
if (saved) setTheme(saved)
}, [])
“localStorage is not defined”
Same cause as above. See the typeof window guard solution.
Hydration Mismatch / FOUC (Flash of Unstyled Content)
Cause: Server HTML doesn’t match client render.
Common causes:
- Missing
hydrateRoot: UsingcreateRootinstead ofhydrateRoot
// main.jsx
const root = document.getElementById('root')
if (root.dataset.serverRendered) {
// SSR content exists - hydrate it
ReactDOM.hydrateRoot(root, <App />)
} else {
// No SSR - create fresh
ReactDOM.createRoot(root).render(<App />)
}
- Inline
<style>tags in JSX: React 18 handles these differently in SSR vs client
// Wrong
return <div style=><style>{`.foo { color: red }`}</style></div>
// Correct - use external CSS or style props
return <div style= />
- Random values at render time:
Math.random(),Date.now()
// Wrong - different on server vs client
return <div>{Math.random()}</div>
// Correct - use useEffect
const [value, setValue] = useState()
useEffect(() => {
setValue(Math.random())
}, [])
Prerendering Issues
Every route prerenders as the homepage
Cause: AppLayout.jsx imports BrowserRouter somewhere in its module graph.
Solution:
- Create a separate
AppLayout.jsxthat NEVER importsBrowserRouter - Only import
Routes,Route,useLocation - Wrap in
BrowserRouterin yourApp.jsx
// AppLayout.jsx - NO BrowserRouter!
import { Routes, Route } from 'react-router-dom'
// ... never import BrowserRouter here
// App.jsx - BrowserRouter goes here
import { BrowserRouter } from 'react-router-dom'
import AppLayout from './AppLayout'
export default function App() {
return <BrowserRouter><AppLayout /></BrowserRouter>
}
Debugging: Check every file that AppLayout imports - none can import BrowserRouter.
Route with trailing slash generates wrong canonical URL
Cause: Mismatch between route path and desired URL.
Solution:Be consistent in your ssr.config.js:
// If you want /about/ (with trailing slash)
{ path: '/about/', meta: {...} }
// If you want /about (no trailing slash)
{ path: '/about', meta: {...} }
React Router v6 handles both, but pick one style and stick with it.
Infinite redirect loop on Cloudflare Pages
Cause: Including /* /index.html 200 in _redirects after prerendering.
Solution: Remove the SPA fallback rule. Once prerendered, every route has its own index.html. The fallback causes CF Pages to rewrite to /index.html, which then matches the rule again.
# _redirects after prerendering - keep only rules that map paths with no
# prerendered file. The line /index.html 200 must be gone entirely.
/status/* /status.html 301
SEO Issues
Duplicate meta tags
Cause: Meta tags in both index.html and injected by prestruct.
Solution:Remove meta tags from your index.html - let prestruct inject them:
<!-- Remove these from index.html -->
<title>...</title>
<meta name="description" content="...">
<!-- Keep only the placeholder -->
<title>[title]</title>
<meta name="description" content="[description]">
Changing 404 robots behavior
The 404 page ALWAYS carries:
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet, noimageindex" />
There is no config switch for this (404s should not rank). If you genuinely
need different behavior, edit scripts/prerender.js in your own project
(template copies are user-owned, engine files are not).
“$120” becomes corrupted in descriptions
Cause: String.replace() interprets $1, $2 as regex backreferences.
Solution:Prestruct automatically escapes $ with $$$$. If manually replacing, do the same:
const desc = description.replace(/\$/g, '$$$$')
Cloudflare Pages Issues
Build fails on Cloudflare but works locally
Check:
- Node.js version set to 18+ in Cloudflare settings
- All dependencies in
package.json(not justpackage-lock.json) - Build command matches local:
npm run build
Site shows “404” for all routes
Cause: dist/ lacks per-route HTML – the build stopped after vite build,
or prerendering failed: the CLI now exits 1 and lists every failed route, so
this usually shows up as a failed build, not as a silently-deployed 404.
Solution:
- Check
dist/about/index.html(etc.) exists after a local build - Run
npx prestruct validate– it exits 1 on a bad config and prints every diagnostic line-by-line. (Non-strict build runs only print diagnostics; setPRESTRUCT_STRICT=1to make them fatal there.) public/_redirectsis NOT required for routing on Cloudflare Pages
Assets return 404
Cause: Asset paths don’t match what’s in your dist/.
Solution:Check that your Vite config outputs to dist/ and that paths in HTML match the hashed filenames.
Proxy Issues
Proxy returns 502 Bad Gateway / render failures
Cause: Target URL unreachable or timing out.
Check:
- Worker:
PRESTRUCT_TARGET_URLvar; VPS:config.proxy.targetUrl(falls back toconfig.siteUrl) - Target server is running and accessible
- No firewall blocking the request
On unrenderable targets both runtimes fall through to a 302 to the live site rather than 5xx.
Proxy cache-bust quietly ignored
Cause: PRESTRUCT_SECRET not set where the proxy runs (wrangler secret on
the Worker, env var on the VPS) or the x-prestruct-refresh header value does
not match.
Note: an UNSET secret never means “allow all” – refresh is disabled entirely, and each runtime warns about it on boot. Compare is constant-time in both runtimes.
Debugging Tips
Watch the build’s own diagnostics
Prerender logs per-route status (ok /route, cached /route) and any
[prestruct] config: validation lines – no debug flag needed. To fail on
diagnostics in CI: PRESTRUCT_STRICT=1.
Check prerendered output
Look in dist/ after build:
ls -la dist/
cat dist/about/index.html | grep -E '<title>|<meta'
Test locally with Cloudflare Pages
npx wrangler pages dev dist
Inspect hydration
Add this to see server vs client content:
console.log('Server content:', document.getElementById('root').innerHTML.substring(0, 200))
Next steps
- Config Reference - avoid the config-shaped failures above
- Migration Guide - moving from another setup