SPA is Overrated — Go Hybrid

The debate between SPA and MPA is a false choice. A hybrid architecture gives you the best of both worlds, and it's easier to set up than you think.

SPA is one of those terms that sounds more mysterious than it is. Strip away the jargon and it just means the browser is doing the rendering. Your server sends a near-empty HTML shell, JavaScript boots up, fetches some data, and builds the page in the client. That’s it.

A few years ago, SPAs were the dominant paradigm. React, Angular, Vue — they all leaned into it hard. And there were good reasons for it. Once your app was loaded, navigation was fast. Interactions felt native. You could build rich, stateful UIs without full page reloads.

Those reasons haven’t disappeared. But the pendulum has swung too far, and a lot of teams are now living with the consequences of choosing SPA where they shouldn’t have.

What actually changed in the browser

Before getting into architecture, it’s worth asking: has the web fundamentally changed? Not really.

WASM got serious. CSS got view transitions, container queries, :has(). JavaScript got async/await, optional chaining, top-level await in modules. The DX is miles better than it was in 2015. But the core model — browser requests a document, parses HTML, renders — hasn’t changed. What has changed is that the platform itself has gotten good enough that the case for client-side rendering everything is a lot weaker.

Your browser can render HTML incredibly fast. Static HTML arrives pre-rendered, no JS required to paint the first frame. A proper server-rendered page will almost always win on first load, on low-end devices, and in search engine crawlers. The SPA wins when the user is already on the page and you need smooth, stateful transitions.

The false choice

The mistake is treating this as either/or.

“Should we build this as an MPA or a SPA?” is the wrong question. The better question is: which parts of this product actually benefit from client-side rendering, and which parts are just suffering because of it?

A marketing site doesn’t need to be a SPA. A documentation site doesn’t need to be a SPA. A blog — obviously — doesn’t need to be a SPA. These are mostly static content that SEO crawlers need to read, that first-time visitors need to load fast on mobile, and that don’t have complex state that needs to survive navigation.

But a data-rich dashboard with real-time updates? A collaborative editor? A product with complex local state that’s meaningless to index? Those actually benefit from client-side rendering.

Most products have both. The right architecture handles both.

A real example from a Next.js project

I recently had a case that made this concrete. A Next.js application with two very different kinds of pages:

  • Public-facing marketing, pricing, and blog content — needed proper SEO, fast first paint, and social sharing previews.
  • User account pages — settings, billing, profile — locked behind authentication, zero SEO value, high interactivity.

The instinct is to pick one approach and apply it everywhere. But that’s the wrong instinct.

For the public pages, server-side rendering (or static generation) was the obvious answer. Here’s the pattern:

// app/pricing/page.tsx
export const dynamic = 'force-static';

export async function generateMetadata() {
  return {
    title: 'Pricing — Acme',
    description: 'Simple, transparent pricing for every team.',
    openGraph: { type: 'website' },
  };
}

export default async function PricingPage() {
  const plans = await getPlans(); // runs at build time
  return <PricingTable plans={plans} />;
}

The page is pre-rendered. The crawler gets full HTML. The user gets content in milliseconds with no JS required to paint.

For the settings pages, the situation is different. The content is user-specific, gated behind a session, and full of interactive state — form validation, optimistic updates, preference toggles. No crawler will ever see it. No one will share a link to it on Twitter.

// app/settings/page.tsx
export const dynamic = 'force-dynamic';

export default async function SettingsPage() {
  const session = await getSession();
  if (!session) redirect('/login');

  // Just fetch the initial data server-side,
  // hand off to a client component for the rest
  const user = await getUserById(session.userId);
  return <SettingsShell initialUser={user} />;
}
// components/SettingsShell.tsx
"use client";

export function SettingsShell({ initialUser }: { initialUser: User }) {
  const [user, setUser] = useState(initialUser);
  // Full SPA-like experience from here — optimistic updates,
  // local state, client-side navigation between settings sections
  return <SettingsLayout user={user} onUpdate={setUser} />;
}

The server does the auth check and the initial data fetch. The client takes over for everything interactive. Both pages live in the same Next.js project. No separate infrastructure.

The key insight

A SPA doesn’t have to mean your entire app renders on the client. It just means a component or a section of your app does. The island model, server components, 'use client' boundaries — these are all ways of saying: be intentional about where rendering happens.

The hybrid approach isn’t a compromise. It’s the correct default. Start by asking what each page actually needs:

  • Will it be indexed by search engines? → Server-render it.
  • Does it need to be fast on first load for a cold visitor? → Server-render it.
  • Is it behind auth and full of real-time state? → Let the client handle it.
  • Is it somewhere in between? → Server-render the shell, hydrate the interactive bits.

Most products will have pages in all four categories. Design the architecture to support that from the start, and you’ll never have to rip apart your routing layer six months in because SEO suddenly matters.

Don’t wait for the refactor

The mistake I see teams make is starting with “we’ll just build it as a SPA for now and add SSR later if we need it.” SSR-later is one of the most painful migrations in frontend. Data-fetching logic is tangled inside components. State is managed globally. Routes assume the client owns everything.

The hybrid pattern is cheap to start with and pays off immediately. In Next.js, the App Router is hybrid by default — every component is a server component unless you say otherwise. In Astro, you opt individual components into client-side rendering with a directive. The default is already the correct one.

Start hybrid. Stay hybrid. The time to decide between SPA and MPA is before you write a single route, and the answer is almost always: both.