Chapter 24
Hybrid — SPA Shell + Pre-rendered Pages
The most common real-world React deployment shape — pre-rendered marketing at /, SPA at /app/*. Shared design system, deployment, and routing handoff.
Published 2026-05-23
🎯 Chapter Goal — After this chapter you can architect the most common real-world deployment shape — a pre-rendered marketing/docs surface at
/wrapping a React SPA at/app/*— including the shared component library, the routing handoff, and the deployment configuration.🧭 Prerequisites — Ch 22 (SSG spectrum), Ch 23 (SSG tools), Ch 5 (routing).
🔹 24.1 Why most real products are hybrid
Three constituencies on the same domain:
- Anonymous visitors — googling, comparing, evaluating. Need fast, SEO-friendly, content-heavy pages. SSG fits.
- Authenticated customers — using the product. Need interactive, stateful, per-user UI. SPA fits.
- Search engines + social-share previews — need server-rendered HTML or pre-rendered HTML for the marketing surface.
A pure SPA serves the customers perfectly and the others poorly. A pure SSG flips that. The hybrid pattern serves all three.
┌──────────────────────────────────────────────────────────────────┐
│ acme.example │
├──────────────────────────────────────────────────────────────────┤
│ / ── SSG (Astro / Next static) ── public marketing │
│ /pricing ── SSG │
│ /docs/* ── SSG │
│ /blog/* ── SSG │
│ /login ── SSG (or SPA — both work) │
│ /app/* ── SPA (React Router / TanStack Router) │
└──────────────────────────────────────────────────────────────────┘
▲ ▲
Pre-rendered HTML, fully crawlable. JS bundle, per-user state.
🔹 24.2 Domain layout — / SSG + /app/* SPA
The minimum architecture:
- One repo (monorepo, Ch 19) with two apps:
apps/marketing/(Astro or Next) andapps/web/(Vite SPA). - A shared
packages/ui/design system (Ch 11) consumed by both. - A reverse proxy / edge config that routes
/app/*to the SPA bundle, everything else to the marketing build.
acme/
├── apps/
│ ├── marketing/ ← Astro; outputs static HTML to dist/
│ └── web/ ← Vite SPA; outputs to dist/ (under /app)
├── packages/
│ └── ui/ ← @acme/ui consumed by both
└── package.json
Or, deploying separately:
acme.example→ marketingapp.acme.example→ SPA
Same shape, different DNS instead of path routing.
🔹 24.3 Shared component library across both worlds
The library has to work in both environments:
- SSG / Astro: imports run at build time. The component code must execute without browser globals (
window,document,localStorage) at module load. - SPA: imports run at runtime in the browser. Browser globals available; React hooks rendered normally.
Rules for the shared library (@acme/ui, Ch 11):
- No top-level
useState(you’d only see that as a misuse anyway). - No browser-only globals at module load. Defer to
useEffect:
// ❌ breaks Astro build
const theme = localStorage.theme ?? 'light';
export const Button = (...) => /* uses theme */;
// ✅ works in both
export const Button = (...) => {
const [theme, setTheme] = useState('light');
useEffect(() => { setTheme(localStorage.theme ?? 'light'); }, []);
// ...
};
- Components rendered inside Astro islands (
client:visible) re-mount client-side; treat them as SPA components. - Components rendered in Astro pages without an island directive are HTML-only — they need to render correctly without any client JS at all.
Stylesheets — both worlds need the same CSS. The library exports a tokens.css (Ch 11.6); both apps import it.
🔹 24.4 Deployment configurations
Vercel — two projects, one custom domain (modern)
The 2026 Vercel idiom is one project per app, both attached to the same custom domain with path-prefix routing. The legacy vercel.json builds array (which used to deploy two apps from one project) is deprecated; new projects shouldn’t use it.
Project A — marketing (apps/marketing)
- Framework preset: Astro (or whatever you picked in Ch 23).
- Root directory:
apps/marketing. - Domain:
acme.example(default route catches everything).
Project B — web SPA (apps/web)
- Framework preset: Vite.
- Root directory:
apps/web. - Domain:
acme.examplewith path/app/*.
📄 apps/web/vercel.json — SPA fallback so deep links work
{
"rewrites": [
{ "source": "/app/(.*)", "destination": "/index.html" }
]
}
When a request to acme.example/app/dashboard hits Vercel, the routing layer matches Project B (path prefix /app/*), invokes its rewrites, and serves the SPA’s index.html. React Router takes over client-side.
The benefit of two projects: each app has its own build, its own deploy log, its own preview URL per PR, its own analytics. The cost: one extra project in your Vercel dashboard.
⚠️ If you see a legacy repo with "builds": [...] in a root vercel.json, that’s the deprecated form. Vercel still honours it but logs a deprecation warning. Migrate when convenient.
Cloudflare Pages — separate projects, one custom domain
Easier: two Cloudflare Pages projects, one custom-domain config with path-based routing in the dashboard. Same architecture, different deployment shape.
Static-host generic (Nginx)
location /app/ {
try_files $uri $uri/ /app/index.html; # SPA fallback
}
location / {
try_files $uri $uri/ =404; # SSG; 404 if not found
}
The try_files … /app/index.html line is the SPA-fallback pattern — any unknown URL under /app/ serves the SPA’s index.html, and React Router handles routing client-side.
🔹 24.5 The routing handoff
When the user clicks a link inside the marketing site that goes into the app:
// In Astro / marketing:
<a href="/app/dashboard">Open dashboard</a>
This is a full page navigation — the marketing JS unloads, the SPA bundle loads, React mounts at /app/dashboard. Slower than in-app routing, fine for an infrequent transition.
When the user clicks a link inside the SPA that goes back to marketing:
// In the SPA:
<a href="/" onClick={(e) => { /* let the browser do a full navigation */ }}>
Back to acme.example
</a>
Again: full page navigation. Don’t use React Router’s <Link> for cross-boundary URLs — <Link> assumes the destination is also in the SPA, and you’ll get a blank screen.
Mock — the user journey across the boundary
Google ── search ──▶ acme.example/pricing (SSG; fast HTML)
│
▼ click "Sign up"
acme.example/signup (still SSG; or SPA — either)
│
▼ successful signup
acme.example/app/onboarding (full nav; SPA mounts)
│
▼ React Router navigation
acme.example/app/dashboard (in-SPA, no full nav)
The cost of the boundary crossing is one extra HTTP round-trip per crossing. Acceptable for the once-or-twice-per-session transitions; would be annoying for navigation between dashboard pages (which is why those stay inside the SPA).
🪤 Common Pitfalls
- Using React Router
<Link>for cross-boundary navigation — blank screen. - Components in the shared library accessing
windowat module load — Astro build breaks. - Forgetting the SPA fallback in your hosting config —
/app/dashboardreturns 404 on hard refresh. - Two design tokens (one for marketing, one for SPA) — they drift; users see two designs.
- Trying to share auth state across the boundary client-side — better to set an
httpOnlycookie that the SPA reads on load. - Pre-rendering the SPA shell at
/app/index.htmlinstead of letting the SPA render it — extra weight, no benefit.
✅ Recap
- Most real products are hybrid — SSG marketing + SPA app under one domain.
- One repo (monorepo); two apps; one shared component library.
- Library code must work in both worlds — no browser-globals at module load.
- The boundary crossing is a full page navigation; cheap if infrequent.
- Deployment config is the SPA-fallback pattern.
🔗 Further Reading
- https://docs.astro.build/en/guides/integrations-guide/react/ — React inside Astro islands.
- Vercel / Cloudflare hybrid-deployment docs.
- Ch 11 (shared library), Ch 19 (monorepo), Ch 23 (SSG tools).
In the book — not on the site
Each topic has an 🧠 Under-the-hood subsection — the algorithm, the data structures, what React DevTools surfaces, debugging recipes. Plus a 🧪 hands-on lab per chapter with a starter repo. Reserved for the book.