Deploy

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

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

build cost

About 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

sh
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

ssr.config.js
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',
  proxy: {
    url:       null, // optional render proxy, e.g. 'https://proxy.yoursite.com'
    secret:    null, // set only when you enable proxy cache refresh
    targetUrl: null, // optional upstream render origin (defaults to siteUrl)
  },

  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.',
      },
    },
  ],

  buildJsonLd() {
    return [
      {
        '@context': 'https://schema.org',
        '@type':    'Organization',
        name:       'Your Org',
        url:        'https://yoursite.com',
      },
    ]
  },
}
Apostrophe rule: use double quotes for strings 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 here, 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

js
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.',
  })
}

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

js
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

package.json
"build": "vite build && node scripts/inject-brand.js && node scripts/prerender.js"

7. Optional: enable the render proxy for dynamic bot routes

Keep build-time prerendering for static routes, then add the proxy to cover routes not listed in ssr.config.js (search pages, pagination, dynamic IDs, recently changed content). Humans still get your normal Cloudflare Pages site.
ssr.config.js (proxy block)
proxy: {
  url:       'https://proxy.yoursite.com', // VPS or Worker URL
  secret:    'set-the-same-value-as-PRESTRUCT_SECRET',
  targetUrl: null,                         // defaults to siteUrl
  botList:   ['Googlebot', 'bingbot'],
}

Use scripts/proxy.js for VPS or scripts/proxy.worker.js for Cloudflare Workers. Full setup: README-proxy.md.

8. Remove SPA fallback from public/_redirects

Remove /* /index.html 200 if present. Prestruct gives every route its own HTML file. The SPA fallback creates an infinite redirect loop with Cloudflare Pages' Pretty URLs feature.

9. Guard any localStorage / window access

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