A React single-page app is the default proposal for too many internal products, even when most screens are forms, tables, and links. For a tech lead with 15–30 engineers, that default is usually wrong: it creates a second application runtime to operate without making the core work easier. Start with server-rendered pages and add browser-side behavior where a user task demonstrably requires it.
A single-page app makes every feature pay for a decision few features need
The attraction of a React 19 application is understandable because one component model can express both simple forms and demanding interactive screens. That range is also the trap: a team can choose the most flexible rendering model before it knows whether its users need that flexibility. Once React, Vite 7, a client-side router, and an API layer become the standard path, even a new approval form tends to acquire client state, loading states, and API error handling because those are the paths the team has built.
I partly disagree with Scope creep starts after the UI framework choice, not before: treating selection as the start of the problem lets an earlier policy escape scrutiny—the assumption that every new screen belongs in a browser application. A framework does not force a team to add features, but a mandated application shell makes a modest request look like a cheap addition to an existing system. The cost appears later in navigation, authentication, testing, and incident response.
Consider an organisation with several product squads but only one or two people regularly available for shared frontend work. Those people become reviewers for dependency changes and owners of failures that cross page, API, and build boundaries because nobody else has the same context. The constraint is reviewer capacity, not whether the team can hire a developer who knows React. A server-rendered Django 5.2 page keeps validation, permissions, and rendering close to the request handler, so a squad can often change a form without coordinating an API contract and a client deployment.
I would not make React the required starting point for every product surface, because a default should make the common change cheap rather than make the most sophisticated change possible. That is a claim about the work mix, not a claim that server rendering is always faster. If your actual backlog is dominated by drag-and-drop editing, offline work, or sustained manipulation of data without navigation, the work mix may justify a different default.
Server-owned navigation is a useful constraint, not a missing feature
For ordinary create, review, and approve journeys, let the server own the URL, authorization decision, and canonical page. An HTTP 303 redirect after a successful POST prevents a refresh from resubmitting the form; server-side validation can return the form with errors beside the relevant fields. These are useful defaults because the browser already understands links, history, and form submission. They also leave fewer opportunities for client state to disagree with the record the server will save.
This small Python 3 example runs without a package install. Save it as demo.py, run python3 demo.py, and visit http://127.0.0.1:8000:
from wsgiref.simple_server import make_server
from urllib.parse import parse_qs
from html import escape
notes = []
def app(e, respond):
if e["REQUEST_METHOD"] == "POST":
data = e["wsgi.input"].read(int(e.get("CONTENT_LENGTH") or 0))
notes.append(parse_qs(data.decode()).get("note", [""])[0])
respond("303 See Other", [("Location", "/")])
return [b""]
body = "<form method=post><input name=note><button>Add</button></form>"
body += "<ul>" + "".join(f"<li>{escape(n)}</li>" for n in notes) + "</ul>"
respond("200 OK", [("Content-Type", "text/html; charset=utf-8")])
return [body.encode()]
make_server("127.0.0.1", 8000, app).serve_forever()
The example demonstrates the request–redirect–page pattern, not a production notes service: its data vanishes on restart, and it lacks authentication, CSRF protection, and durable storage. Those omissions matter because server rendering does not remove security work; it gives the team a clear place to enforce it. In Django, use its CSRF middleware and template escaping rather than copying this toy server into a deployment.
Browser-side enhancement still has a job. HTMX 2.x can replace a table fragment after a filter change, and Alpine.js 3 can manage a disclosure or a short-lived dialog, because neither interaction needs to own the entire page. Keep the underlying link or form usable where practical, so a failed script does not turn navigation into a blank control. WCAG 2.2 checks still apply to focus, error messages, and keyboard operation; HTML generated on the server is not automatically accessible.
The browser should take ownership when the interaction genuinely cannot tolerate a round trip. That boundary is more useful than a rule against JavaScript because it names the user-visible reason for introducing client state. It also gives reviewers a question to ask on each change: what task becomes materially worse if this interaction remains a request to the server?
React and server rendering each win under different workloads
Make the choice explicit: compare Django templates with selective HTMX against React 19 with Vite 7 and an API. Django wins when users move among records, submit forms, and share URLs, because the server can render each authorized state from one request. Its cost is that rich interactions may accumulate awkward fragment endpoints, and developers must maintain clear boundaries between full pages and partial responses.
React wins when users spend long sessions editing a complex workspace—such as rearranging a graph while inspecting live results—because local state can update continuously without rebuilding a document after every gesture. Its cost is a separately built and tested client, API contracts, and deliberate handling of loading, stale data, and failed requests. A server-rendered application can also use WebSocket or Server-Sent Events, so live updates alone do not settle the decision; the deciding factor is how much coordinated state the browser must manipulate between server exchanges.
As a planning threshold to tune, ask a squad to identify at least two core journeys that require sustained client-side state before approving a full SPA for its product. The number is deliberately modest because the exercise is meant to elicit concrete user tasks, not to block a legitimate editor. “The dashboard should feel modern” does not qualify, since it describes an impression rather than an interaction that fails with server navigation.
I also disagree with The Hidden Upkeep of Frontend Frameworks for Backend Devs on where to draw the organisational boundary: upkeep is a product ownership question, not a surcharge attributable only to backend developers. A dedicated frontend specialist still has to decide who upgrades dependencies, investigates build failures, and supports the application during leave or staff turnover. The Node.js project’s published release schedule places Node.js 24’s end of life in April 2028, which makes runtime upgrades a scheduled ownership task rather than an occasional cleanup.
Neither option is free of dependencies. The relevant difference is whether the team has chosen to maintain an additional, long-lived stateful client because its users benefit from one. That decision can be sound; making it implicitly on every project prevents the tech lead from comparing its benefit with the ownership it requires.
A pilot should expose operational work before it rewards a polished demo
Build the same representative journey both ways rather than comparing a polished React prototype with an unstyled server page. Choose a journey with permissions, an invalid submission, a filtered list, and a return visit through a shared URL, because happy-path screenshots hide the work that tends to consume review and support time. Give each implementation the same acceptance criteria and record the engineering hours spent after the first working demo.
Use Playwright to run the same browser journey against both candidates: create a record, submit invalid data, follow a filtered URL, go back, and retry after a simulated failed request. Include keyboard navigation and focus checks, because a fragment replacement and a client-side route change can each put focus somewhere unexpected. A Lighthouse score can identify a page-level performance issue, but it cannot tell you which team owns a broken approval flow.
If your real-user monitoring reports a measured p95 navigation time of 420 ms on the current server-rendered journey, keep that figure as a baseline rather than declaring a faster-feeling prototype the winner. Compare equivalent journeys on the same network and devices, because a demo with warm caches and little data is an unreliable performance test. Record the p95 alongside failed requests and completion rate so that optimizing one page transition does not obscure users who cannot finish the task.
Set a trial budget of six weeks for the pilot, including one dependency update and one simulated production incident, because the first implementation rarely reveals the cost of changing or repairing it. For the SPA, run Dependabot or npm audit, update a package, rebuild, and trace a failed API request through the browser and server. For the server-rendered candidate, update its dependencies, test CSRF-protected submission, and trace a failed form POST. Use OpenTelemetry spans or equivalent request IDs where they help connect the user action to server logs.
Finally, name the owner of each failure before choosing the stack. If the answer to “who fixes a broken client build on Friday?” is “the person who originally set up Vite,” the team has discovered a staffing dependency, not a technical preference. If the server candidate needs a growing collection of fragile fragments to deliver the primary task, it has revealed a different cost. Choose from those observed changes and failures, because that evidence describes the system your organisation will actually have to run.
Count the browser-only journeys before approving another SPA
At the next planning meeting, take the proposed product’s top user journeys and mark which ones genuinely require coordinated state between server requests. Ask the proposed owners to implement one unmarked journey as a server-rendered page before approving an application shell. If that page fails a specific user task, document the failure and reconsider; if it succeeds, do not create a second runtime merely to follow the usual template.



