# SPA vs server-side rendering: which should you choose?

> Choose between a single-page app, server-rendered pages, and interactive islands by looking at navigation, shared state, loading, and maintenance.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-10-21 | Topics: [Web Platform](https://flaviocopes.com/tags/platform/) | Canonical: https://flaviocopes.com/spa-or-server-rendering/

A website with several pages does not automatically need a client-side router.

A website with a lot of JavaScript does not automatically need to render everything in the browser either.

We have enough options now that the old categories can make this decision harder. A React application can send server-rendered HTML. An Astro page can contain a complex interactive editor. A traditional page navigation can animate between documents.

When I choose an approach, I start with what the user does on the page. Reading an article and editing a video create very different requirements. The useful architecture follows from those requirements.

The question is where the interface needs to keep running while the user works.

## Rendering and routing are separate decisions

**Rendering** is the process of producing the interface. With server rendering, the server produces HTML. With client rendering, JavaScript in the browser creates or updates the interface.

**Routing** decides which interface belongs to a URL and how moving to that URL works.

A browser can request a new HTML document for each navigation. Alternatively, a client-side router can intercept navigation and update the current document.

These choices can combine. An application may server-render the initial page, hydrate it in the browser, and handle later navigations through a client router. That is a common hybrid arrangement.

It is still meaningful to ask how much JavaScript the first page needs. Sending HTML from the server does not automatically remove the client runtime.

It is also meaningful to ask what happens on the second navigation. A fast first response does not tell you whether the next route waits for JavaScript and data before showing anything useful.

Evaluate the complete journey. Architecture labels rarely tell you enough on their own.

## What hydration adds

Imagine the server sends HTML for a filterable table. You can see the rows before the client application starts.

If the page uses React, hydration connects the client-side component logic to that existing HTML. React's [hydration documentation](https://react.dev/reference/react-dom/client/hydrateRoot) describes this relationship and the requirement that the initial client output match the server output.

The HTML gives you a useful starting point. The JavaScript still has to load and execute before React-controlled interactions work.

That distinction matters on a slower device. A page can look complete while some controls are not ready. Whether that happens, and how noticeable it is, depends on the framework and how the page handles interaction during startup.

Server rendering can improve the initial experience while still shipping too much JavaScript. Client rendering can be appropriate for an interface whose useful state only exists after the user starts editing.

You have to inspect both the output and the work required to make it usable.

For a deeper explanation of the React side, see my [server-side rendering guide](https://flaviocopes.com/react-server-side-rendering/).

## A content page has a different starting point

On a blog post, most of the page is text, links, and images. The browser already understands how to display those. It also knows how to open a link in another tab, restore history, select text, and submit a basic form.

A search box may need JavaScript. A course selector may need JavaScript. Neither requires the article itself to become part of a large client application.

I like starting content sites with HTML and adding behavior where the page needs it. My preference for [Astro](https://flaviocopes.com/why-i-use-astro/) comes from that shape of project.

Generating HTML at build time also works well when content changes less often than people request it. Pages can be served without running application code for every visit.

Request-time rendering becomes useful when the response depends on current information or the visitor. That does not require making every route dynamic. A site can serve articles as static files and render an account page on demand.

The deployment and cache rules should match those differences.

## Search engines need useful pages too

A public product catalog adds another requirement. People need to find an individual product without entering through the homepage.

Google can render JavaScript. Its [JavaScript SEO documentation](https://developers.google.com/search/docs/crawling-indexing/javascript/javascript-seo-basics) explains that crawling and rendering are separate stages. If the initial HTML only contains an application shell, seeing the product depends on executing the code that loads it. Other bots may not run JavaScript at all.

For a catalog, I would send the product's main content in the initial HTML, through static generation or request-time rendering. The image gallery can still use client code. A client router can still handle later navigation. Providing crawlable content leaves those decisions open.

I would also test the URL directly. Copy a product link into a new browser tab and reload it. The hosting setup needs to serve that route correctly even when the client router has never run.

Missing products need attention. A fallback that returns the same application shell with HTTP status `200` for every URL can create soft 404 problems. Where the server knows a product does not exist, return a real `404`. Google documents alternatives for client-rendered error pages in the same guide.

For a hypothetical shop, my trial would include one available product and one removed product. I would inspect each response, check the rendered page title, then follow a category link to reach another product. That gives me something concrete to review before generating thousands of catalog URLs.

For the private audio editor, discoverability has much less influence on the architecture. I would spend that effort testing unsaved work and recovery. The public pages explaining the editor can use a different rendering approach from the editor itself.

## Islands give interactivity a boundary

An interactive island is a part of a page that manages its own client behavior while the surrounding page remains HTML.

A calculator inside an article is an easy example. The calculator needs state and event handling. The paragraphs around it do not.

Astro's [islands documentation](https://docs.astro.build/en/concepts/islands/) explains its selective hydration approach. You opt individual components into client-side behavior and choose when to load them. Its server islands are a separate feature for independently rendered dynamic content; they should not be confused with client hydration.

I would use a client island for a pricing calculator, a searchable list, or a small product configurator. Those components have clear inputs and a clear boundary.

The boundary becomes less useful if every island needs to coordinate with every other island. If changing a filter updates the sidebar, editor, preview, toolbar, and several dialogs, you are dealing with shared application state.

You can share state across islands. But if doing so starts to recreate an application framework inside the page, consider putting the connected interface inside one application instead.

The point is to choose a boundary that matches the work.

```mermaid
flowchart TD
  accTitle: An island inside an HTML page
  accDescr: An article contains ordinary HTML alongside a calculator island. Only the calculator needs client state and event handlers.
  A[Article page] --> B[Text, links, and images]
  A --> C[Calculator island]
  B --> D[Browser displays HTML]
  C --> E[JavaScript manages inputs and results]
```

## Livewire and HTMX keep rendering on the server

Server-rendered interfaces can update part of a page without reloading the whole document. [HTMX](https://htmx.org/docs/) and [Laravel Livewire](https://livewire.laravel.com/docs/actions) both support this approach.

Imagine an order list with a status filter. You select "Shipped", the server queries the matching orders, and the browser updates the list. The rest of the page stays in place.

With **HTMX**, you add HTML attributes that describe the request and where to put the response. The server usually returns an HTML fragment, which HTMX inserts into the page. You can use it with any backend that serves HTML. My [HTMX tutorial](https://flaviocopes.com/htmx-introduction/) walks through that model.

With **Livewire**, you build Laravel components using PHP and Blade templates. A form submission or button click can call a PHP component method on the server. Livewire renders the updated component, and its JavaScript [compares the HTML and changes the affected elements](https://livewire.laravel.com/docs/morphing). It handles much of the connection between the component's state and the browser for you.

I would consider either for an admin panel with editable records, filters, and pagination. In an existing Laravel application, Livewire fits the tools already there. HTMX works well when I want to keep my server routes and templates and add partial updates where needed.

Both still use JavaScript in the browser. You can also keep small interactions local, such as opening a menu with Alpine.js. Sending those through the server would add an unnecessary wait.

The main tradeoff is the network. An interaction that needs server logic waits for a request and response. For search, you can wait until the person pauses typing before sending a request. For an audio editor's playhead or a drawing canvas, frequent updates belong in the browser. Offline work also needs an explicit client-side plan.

Navigation remains a separate choice here too. HTMX can enhance links through `hx-boost`, and Livewire offers [`wire:navigate`](https://livewire.laravel.com/docs/navigate) to fetch pages in the background and update the current document. The server still produces the HTML. Handling navigation in JavaScript does not require moving rendering into the browser.

## A single-page app earns its place through continuity

Now imagine a browser-based audio editor.

The user has an unsaved project, a selected clip, playback position, and a panel arrangement. Moving between views should preserve that working session. Some interactions happen many times per second.

A persistent client application gives that state a natural home. Its router can change the visible view without discarding the application around it.

A dashboard can have similar needs, although the word dashboard covers a huge range. A few reports with date filters may work well as server-rendered pages. A trading interface with live updates and several coordinated panels has different demands.

I would look at how often the user changes routes, what must survive each change, and how much state connects the screens.

Server-rendered applications have supported authenticated pages for decades, so a login requirement leaves the architecture question open. A private interface can still be a collection of ordinary pages.

Likewise, a public application can benefit from a client router when its work requires continuity. The state model is more useful than the public-versus-private distinction.

## Smooth navigation no longer proves you need a router

A visible flash between pages used to be an easy argument for keeping every navigation inside one document.

Browsers have improved. Chrome's [paint holding](https://developer.chrome.com/blog/paint-holding/) delays replacing the previous page's display during certain navigations so the user does not see an unnecessary blank frame.

That is separate from view transitions.

[Cross-document view transitions](https://developer.chrome.com/docs/web-platform/view-transitions/cross-document) let supporting browsers animate eligible navigations between documents. Both documents need the appropriate setup, and the feature has conditions such as same-origin navigation.

Neither mechanism preserves arbitrary JavaScript state from the previous page. Neither guarantees that a slow server response will feel instant.

They do make it possible to improve the visual experience of a multi-page site without adopting a client router solely for animation. Use them as progressive enhancements and check the behavior in the browsers your readers use.

A transition should also respect reduced-motion preferences. An animation that makes a demo look polished can make repeated navigation unpleasant for someone else.

I would settle the state and rendering decisions before deciding how the navigation should animate.

## Count the requests the user waits for

Performance discussions often stop at bundle size. Bundle size matters, but the request sequence matters too.

A client-rendered route may load JavaScript, discover which API request it needs, wait for data, and then render. If those steps happen in sequence, the network delays accumulate.

A server-rendered route may fetch the same data before sending HTML. That can eliminate a browser-side waterfall, but a slow upstream request can still delay the response.

These are two possible request sequences. The arrows show dependencies, not measured timings.

```mermaid
flowchart TD
  accTitle: A client-rendered request waterfall
  accDescr: In this client-rendered example, the browser receives an HTML shell, loads JavaScript, discovers the API request, waits for data, and renders the content.
  A[Request page] --> B[Receive HTML shell]
  B --> C[Load and run JavaScript]
  C --> D[Discover and fetch API data]
  D --> E[Render useful content]
```

```mermaid
flowchart TD
  accTitle: Fetching data before sending server-rendered HTML
  accDescr: In this server-rendered example, the server fetches data and renders HTML before sending the content to the browser. A slow data request can still delay the response.
  A[Request page] --> B[Server fetches data]
  B --> C[Server renders HTML]
  C --> D[Browser receives and displays content]
```

Caching, parallel fetching, and preloading can change either result. So can moving work away from the critical path.

I would inspect a real journey on a constrained connection. Open a page directly, navigate to another, go back, and reload. Measure when useful content appears and when the main action works.

Also test a visitor who has no cached assets. A fast second visit on your laptop can hide a heavy first visit on someone else's phone.

The architecture gives you opportunities. The implementation decides whether you use them.

## State has costs wherever you put it

A persistent client application needs a plan for stale data.

The user may keep a tab open while another person edits the same record. Navigating within that application does not necessarily cause a fresh document request. Decide when data is revalidated and how conflicts appear.

Server-rendered pages have their own state questions. Unsaved form input may disappear during navigation. A server response may arrive after the user has changed context. Caching can expose stale or incorrect content if its rules ignore authentication.

For a page with a few forms, server rendering plus small enhancements can keep these rules easy to follow. For a complex workspace, centralizing client state may make them easier.

My default would be the approach with the fewest implicit rules for that particular interface.

I would also keep shareable state in URLs where it makes sense. A report's selected date range belongs in a link someone can send. A temporary drag position probably does not. This decision helps both client-routed and document-routed applications.

## Failure behavior deserves a trial

Try the interface when a request fails.

Does a failed navigation leave the previous screen usable? Can a person retry? Does the URL describe the screen they are seeing? If a JavaScript chunk fails to load after a deployment, can the user recover without losing work?

Check that a server-rendered form preserves submitted values after an error. With an island, try it before its JavaScript loads. A client application also needs checks for startup failure, as well as later API failures.

Accessibility belongs in this trial too. Client navigation should handle focus and make meaningful changes understandable to assistive technology. The browser handles some of that during ordinary document navigation, but custom interfaces need to preserve it deliberately.

If you are still learning the browser's native behavior, the free [HTML course](https://flaviocopes.com/courses/html/) is a useful place to start. Knowing what a link and a form already do makes it easier to judge which enhancements you need.

## A product can use several approaches

For a product with a marketing site and a complex editor, I would let them have different defaults.

The marketing pages would start as HTML, with small interactive components where useful. The editor would be one client application if its state and workflow justified it. Account settings might remain ordinary server-rendered forms.

```mermaid
flowchart LR
  accTitle: Different rendering choices within one product
  accDescr: A product can use HTML with islands for marketing, server-rendered forms for account settings, and a persistent client application for its editor.
  A[One product] --> B[Marketing: HTML + islands]
  A --> C[Settings: server-rendered forms]
  A --> D[Editor: persistent client app]
```

That arrangement does create boundaries. You need consistent authentication and navigation. You need to decide how the editor loads and saves data. But those are visible product boundaries, and they can be easier to maintain than forcing every page into the editor's architecture.

For a smaller application, I would start with server-rendered pages and add interactivity as I encountered a concrete need. If the interface grew into a workspace with deeply shared client state, I would reconsider that part.

The next useful step is to build the screen with the most demanding interaction. Use it with a slow connection, navigate away, and return. The state you need to preserve will tell you more than a framework comparison table.
