Use it
Add SEO in minutes.
Works with any existing Vite + React + React Router v6 app on Cloudflare Pages. The only structural change is extracting AppLayout from App.jsx.
Requirements
Vite 5+, React 18+, React Router v6, Cloudflare Pages, Node 18+
~15 minutes. The AppLayout extraction is the only structural change. Everything else is additive.
~2 seconds added to build time for a typical 3-10 route app. Scales linearly with route count.
Integration steps
1. Copy the engine files into your app
# from the prestruct repo
cp scripts/prerender.js your-app/scripts/
cp scripts/inject-brand.js your-app/scripts/
cp templates/src/hooks/usePageMeta.js your-app/src/hooks/2. Create ssr.config.js in your project root
export default {
siteUrl: 'https://yoursite.com',
siteName: 'Your Site',
author: 'Your Org',
tagline: 'What your site does.',
ogImage: 'https://yoursite.com/og-image.jpg',
keywords: 'keyword one, keyword two',
appLayoutPath: '/src/AppLayout.jsx',
routes: [
{
path: '/', priority: '1.0', changefreq: 'weekly',
meta: {
title: 'Your Site | What your site does.',
description: 'Homepage description, 50-160 chars.',
},
},
{
path: '/about', priority: '0.8', changefreq: 'monthly',
meta: {
title: 'About | Your Site',
description: 'About page description.',
},
},
// one entry per route -- unlisted routes won't be prerendered
],
// schema.org JSON-LD -- injected into every page <head>
buildJsonLd() {
return [
{
'@context': 'https://schema.org',
'@type': 'Organization',
name: 'Your Org',
url: 'https://yoursite.com',
},
]
},
}"We're open Mon-Fri" works. 'We\'re open Mon-Fri' breaks the parser.3. Extract AppLayout from App.jsx
/. See how it works.// src/AppLayout.jsx -- NO BrowserRouter, ever
import { Routes, Route, useLocation } from 'react-router-dom'
import { useEffect } from 'react'
function ScrollToTop() {
const { pathname } = useLocation()
useEffect(() => {
if (typeof window !== 'undefined') window.scrollTo(0, 0)
}, [pathname])
return null
}
export default function AppLayout() {
return (
<>
<ScrollToTop />
<Nav />
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="*" element={<NotFound />} />
</Routes>
<Footer />
</>
)
}
// src/App.jsx -- BrowserRouter lives ONLY here
import { BrowserRouter } from 'react-router-dom'
import AppLayout from './AppLayout'
export default function App() {
return <BrowserRouter><AppLayout /></BrowserRouter>
}4. Add usePageMeta to each page
import usePageMeta from '../hooks/usePageMeta.js'
export default function About() {
usePageMeta({
siteUrl: 'https://yoursite.com',
path: '/about',
title: 'About | Your Site',
description: 'About page description.',
})
// rest of your component
}Tip: wrap it to avoid repeating siteUrl
// src/hooks/useMeta.js
import usePageMeta from './usePageMeta.js'
const SITE = 'https://yoursite.com'
export default (args) => usePageMeta({ siteUrl: SITE, ...args })5. Update main.jsx -- use hydrateRoot for SSR content
const root = document.getElementById('root')
if (root && root.dataset.serverRendered) {
ReactDOM.hydrateRoot(root, <React.StrictMode><App /></React.StrictMode>)
} else if (root) {
ReactDOM.createRoot(root).render(<React.StrictMode><App /></React.StrictMode>)
}6. Update package.json build script
"build": "vite build && node scripts/inject-brand.js && node scripts/prerender.js"7. Remove SPA fallback from public/_redirects
/* /index.html 200 if it exists. prestruct gives every route its own HTML file -- the SPA fallback creates an infinite redirect loop with Cloudflare Pages' Pretty URLs feature.8. Guard any localStorage / window access
// Wrong -- throws in Node during prerender
const [theme, setTheme] = useState(localStorage.getItem('theme') || 'dark')
// Correct -- SSR-safe
const [theme, setTheme] = useState(() => {
if (typeof window === 'undefined') return 'dark'
return localStorage.getItem('theme') || 'dark'
})