modern-react-spa

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:

  1. Anonymous visitors — googling, comparing, evaluating. Need fast, SEO-friendly, content-heavy pages. SSG fits.
  2. Authenticated customers — using the product. Need interactive, stateful, per-user UI. SPA fits.
  3. 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) and apps/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 → marketing
  • app.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.example with 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

  1. Using React Router <Link> for cross-boundary navigation — blank screen.
  2. Components in the shared library accessing window at module load — Astro build breaks.
  3. Forgetting the SPA fallback in your hosting config — /app/dashboard returns 404 on hard refresh.
  4. Two design tokens (one for marketing, one for SPA) — they drift; users see two designs.
  5. Trying to share auth state across the boundary client-side — better to set an httpOnly cookie that the SPA reads on load.
  6. Pre-rendering the SPA shell at /app/index.html instead 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

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.

Topics in this chapter (5)
  1. 24.1 Why most real products are hybrid
  2. 24.2 Domain layout — / SSG + /app/* SPA
  3. 24.3 Shared component library across both worlds
  4. 24.4 Deployment configurations
  5. 24.5 The routing handoff