Articles

Migrating a React SPA to server-side rendering: what actually changes

Choose My Plane had been live for several months before I looked at Google Search Console and found essentially nothing. No indexed pages, no impressions, no clicks. The product was working, but users couldn’t find it. And search engines could not see it at all.

A React single-page application renders in the browser. The server delivers a near-empty HTML document and a JavaScript bundle; the browser executes that bundle and builds the DOM. Search engine crawlers have improved their JavaScript execution over the years, but they remain unreliable with SPAs, particularly for content that depends on API calls made after the initial render. If you have built a product on ReactJS and have not explicitly configured SSR or static pre-rendering, there is a reasonable chance your pages are not indexed correctly. The way to confirm this is to check Search Console and to use the “Inspect URL” tool on a page that should be indexed. What you will often find is that the crawler saw the shell but not the content.

Why not Next.js

The obvious solution to a React SPA with no SSR is Next.js. It is the default answer, it is well-documented, and it would have kept the existing component library.

I chose not to use it.

The reasons were mostly practical. Choose My Plane has a Python backend. The comparison data, the cost modelling, the aircraft profiles: all of that logic lives in Python. Adding Next.js would have introduced a Node.js server into the deployment, sitting alongside or in front of the Python application, adding operational complexity without adding capability. The backend would still be Python. The frontend would now require a second runtime, a second deployment configuration, and a second set of dependencies to keep current. And don’t get me started on deployment infrastructure expectations (I’m looking at you, Vercel).

The alternative was to move the rendering to Python entirely, using Jinja2 templates server-rendered by FastAPI. The deployment is simpler: one process, one runtime, the same stack as the backend. The operational surface is smaller. And Jinja2 is not React, but for an application whose pages are mostly data display and form interaction, it is sufficient.

There is a real cost to this choice. Any engineer already familiar with React can read and modify a Next.js frontend without learning much. Jinja2 templates require understanding a different templating model, one I was thankfully experienced with. For my situation, it was the right call.

First principles, not component porting

The temptation in any migration is to replicate the existing product in the new stack. Port each component, match each interaction, get back to feature parity, then stop. This is the wrong frame.

The better question for each page is: what is the user actually trying to do here? The existing implementation is one answer to that question, but it was written in a particular context, with particular constraints, and it accumulated assumptions and compromises along the way. Starting from the page’s job rather than its current implementation produces a better result.

The comparison page at Choose My Plane is the clearest example of this. The original page organised content into five horizontally-scrollable widget sections: mission performance, runway performance, operating costs, capability and limits, dimensions. Some of those sections had limited analytical value and were essentially static value comparisons presented as interactive widgets. The migration replaced that structure. Static comparisons moved into tables, which suit the side-by-side format more naturally and support colour-coded delta indicators (green for better, red for worse) without the overhead of a widget. The interactive budget was spent where it earned its keep: a runway performance calculator that models takeoff distance under local conditions, and a new range map that shows coverage under a user-configurable payload. New visualisations were added that the original page did not have at all: a radar chart comparing five attributes across all selected aircraft, and a performance chart combining climb rate, service ceiling, and range in a single view. The question “what is a buyer actually trying to do here?” produced a different answer than “how do we replicate what we have?”

This approach takes more time than porting would have. It also produces pages that are better suited to what users are trying to do, rather than pages that faithfully replicate the accumulated decisions of the previous version.

What SSR with Python actually looks like

In practical terms, migrating to server-side rendering with Python means that data which previously arrived via API calls in the browser now arrives before the response is sent. A route handler fetches what the page needs, passes it to a template, and returns complete HTML. The compare route is representative: it accepts up to five aircraft slugs as query parameters, fetches the data, and hands it to the template. No client-side fetch, no loading state. The page is complete when it arrives.

For interactivity that genuinely needs to stay client-side, the stack uses Alpine.js: small, declarative, no build step. HTMX handles the cases where a user action should update part of the page without a full reload. The search results list is a good example of how these two work together. On a search, HTMX swaps in a new results partial from the server. The partial includes a JSON block that Alpine reads to sync its local state, and Alpine then renders the individual result items:

{# components/search_results_list.html #}

{# Updated data for Alpine state sync on HTMX swap #}
<script id="search-data" type="application/json">
{
  "results": {{ results.data | tojson }},
  "count": {{ results.count | tojson }},
  "page": {{ page | tojson }}
}
</script>

<template x-for="aircraft in filteredResults" :key="aircraft.slug">
    <div>{% include 'components/search_result_item.html' %}</div>
</template>

<div x-show="filteredResults.length === 0" class="py-12 text-center">
    <p class="text-lg text-slate-600 mb-2">No aircraft match your search</p>
    <button @click="filterQuery = ''" class="text-purple-600 hover:text-purple-700 underline">
        Clear search filter
    </button>
</div>

The equivalent in React was a ListView component receiving data via props, with selection state managed in a parent component and passed down. The HTMX version does less: the server owns the data, HTMX owns the swap, Alpine owns the local filter state. Each layer has a clear responsibility. The interactivity budget is spent where it is actually needed, not applied uniformly.

The Alpine.js trade-off

In-memory React state does not survive page navigation. A React SPA sidesteps this because it does not do full page navigations: the URL changes but the JavaScript runtime persists. With SSR and multi-page navigation, each page load is a fresh document, and in-memory state is gone.

For Choose My Plane, this mattered for the comparison tool. A user browsing aircraft profiles and adding them to a comparison needs that selection to persist across pages. In React, this lived in application state. In Alpine.js, it lives in browser storage.

For CMP, this was actually an improvement. Browser storage persists across sessions without any serialization logic: a user can close the tab, return the next day, and their comparison is still there. In React, that would have required explicit persistence code. The global nature of browser storage was a feature here, not a liability.

The trade-off surfaces in applications where stored state can become invalid: if the storage references IDs that have been deleted, or if the stored format changes after a deployment. Those cases require validation on load and graceful handling of stale state. Whether that is a meaningful cost depends on the application. For CMP it was not. For an application with more volatile underlying data, it would be worth thinking through before committing to the approach.

What changed after the migration

For months as a SPA, Choose My Plane had no measurable organic search presence. In the first month after the migration went live, two user registrations came through.

One of those sessions lasted over an hour.

That is a small number. It is also the beginning of something that was not happening at all before. Search visibility takes time to compound: pages need to be indexed, rankings need to stabilise, content needs to accumulate. The migration was not the end of the SEO work. It was the precondition for doing it.

When this is and is not the right move

A React SPA is the right choice when your application has significant client-side state that would genuinely degrade under a server-rendered model: real-time collaborative features, complex UI that updates continuously from live data, the kind of interactivity that benefits from React’s component model throughout.

It is often the wrong choice when it was selected as a default. A lot of applications that were built on React SPAs because that was what the team knew, or because it was what the AI-assisted tools produced, would be better served by server-rendered HTML. If the pages are primarily content display and form interaction, the overhead of client-side rendering is cost without benefit, and the SEO implications are a real liability.

If you are already on Next.js with SSR configured correctly, this does not apply to you. If you are on React with no SSR and your search visibility is poor, it is worth confirming that the SPA architecture is the cause before committing to a migration. Check Search Console, use the URL inspection tool, and look at what the crawler actually sees.

If the migration is the right move and you are trying to decide what stack to move to: the choice between Next.js and a backend-native approach depends on your existing stack. If you are already running Python, moving rendering to Jinja2 gives you a simpler deployment at the cost of a less familiar frontend model. If your team is primarily JavaScript engineers and you have no strong backend language preference, Next.js is the lower-friction path.

Get in touch if that is where you are.

If this is relevant to something you're building, I'd like to hear about it.

Get in touch