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.

stack

Vite 5+, React 18+, React Router v6, Cloudflare Pages, Node 18+

time

~15 minutes. The AppLayout extraction is the only structural change. Everything else is additive.

build cost

~2 seconds added to build time for a typical 3-10 route app. Scales linearly with route count.

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',
      },
    ]
  },
}
Apostrophe rule: use double quotes for any string containing a contraction."We're open Mon-Fri" works. 'We\'re open Mon-Fri' breaks the parser.

3. Extract AppLayout from App.jsx

Critical: AppLayout must never import BrowserRouter -- anywhere in its module graph. BrowserRouter initializing at SSR time causes every route to prerender as /. 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

Remove /* /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'
})