"Serious products need a frontend framework" sounds mature until you inspect the actual product surface.
I think the better question is simpler and more demanding: what surface do I actually have, and what extra runtime, build, and ownership complexity does a framework buy me here?
That is the question I used for PolicyNIM.
Dan McKinley, former engineer at Etsy, puts this test plainly in his widely-cited "Choose Boring Technology" essay: "One of the most worthwhile exercises I recommend here is to consider how you would solve your immediate problem without adding anything new." That is what I call a forcing function. It makes you prove the case for the new thing before you let it in.
I ran that test. Here is what I found.
PolicyNIM is a Python-first MCP product. Its two real public surfaces are a CLI and an MCP server.

The hosted beta portal is important, but it is a narrow companion surface inside that product: GET /beta for self-serve access, GET /healthz for hosted readiness, and authenticated /mcp for the actual agent traffic. That surface needs sign-in, API key issuance, copyable setup commands, and a small amount of presentational UI. It does not need a separate client application, a hydration strategy, or a second runtime to stay alive.

So I used server-rendered Jinja templates with plain HTML, CSS, and JavaScript.
That was not me avoiding a framework because PolicyNIM is simple. It was me choosing scope-fit architecture because this surface is narrow, and I wanted the architecture to stay honest about where the real complexity lives.
This Was Not An Anti-Framework Decision
I am not opposed to frameworks. I use them when the problem is actually shaped like a framework problem.
David Heinemeier Hansson, creator of Rails, once made this point about tools in general: the same thing that is best in the world for one problem can become the worst idea in the world when you apply it to a problem that does not fit it. That is not a criticism of the tool. It is a statement about fit.
If I have heavy client-side state, optimistic updates, client-side routing, a design system shared across multiple surfaces, or a frontend team shipping independently from the backend — a framework earns its keep. In those cases, the framework multiplies leverage.
But that is exactly the point: frameworks are multipliers, not defaults.
If the surface is small, they multiply complexity too.
That tradeoff matters even more in AI-era products because teams are already drowning in accidental sprawl. Adding a second toolchain because it feels more "serious" is the architecture equivalent of bringing a full trauma cart to a routine vitals check. The equipment is not bad. It is just mismatched to the task.
Why The Hosted Beta Portal Is Not An SPA-Shaped Problem
PolicyNIM's core job is not rendering a rich client. Its job is grounded policy retrieval and preflight guidance for coding agents. The architecture reflects that on purpose: typed contracts, explicit adapters, application services, and interface layers that stay easy to inspect from the terminal and from the repo.
The hosted beta portal is attached to that runtime. It exists to get a user from zero to a working MCP connection without operator handoff. That is a useful product surface, but it is still a bounded operational surface.
You can see that boundary directly in src/policynim/interfaces/mcp.py. The same interface layer registers the hosted beta routes, readiness route, and MCP surface. The UI is not hiding behind a second application boundary. It is packaged alongside the thing it serves.
Here is the shape in miniature:

That is not a half-built SPA. It is an interface-layer route that renders a narrow hosted beta portal for a Python-first MCP product.
The footprint supports the same conclusion. The UI is three top-level templates — landing.html.j2, dashboard.html.j2, and page.html.j2 — backed by about 13 KB of CSS, about 2 KB of page JavaScript, and a tiny theme-init script. That is exactly the kind of surface where plain web technology stays legible longer than a framework stack.
What Would a Framework Have Added?
If I had pulled in a frontend framework here, I would not have been buying "modernity." I would have been buying another toolchain, another architectural boundary, and another place for drift.
Joe Armstrong, the creator of Erlang, described this dynamic with a precision I have never been able to improve on: "The problem with object-oriented languages is they've got all this implicit environment that they carry around with them. You wanted a banana but what you got was a gorilla holding the banana and the entire jungle."
Swap "object-oriented languages" for "frontend frameworks" on a narrow server-rendered surface, and the quote holds. I wanted GET /beta. What a framework would have handed me was a gorilla holding GET /beta along with an entire build system, a second dependency graph, and a deploy contract that lives completely outside the Python runtime.
Concretely, that means at least:
a separate frontend build path
a second dependency ecosystem and update surface
a new packaging and deploy boundary
more ways for auth behavior, route behavior, and asset behavior to diverge from the Python runtime
There is no package.json in this repo. That is not an omission. It is a decision about ownership. PolicyNIM already has enough real complexity: provider calls, fail-closed grounding, hosted auth, local index readiness, and MCP transport behavior. The beta portal should not pretend to be the center of the system.
In practice, a framework here would have created more maintenance work around the product than inside the product.
That matters because "best practices" detached from scope become cargo cults. Small teams do not need zero guardrails. They need the right guardrails. For this surface, the right guardrail was keeping the UI inside the same Python boundary as the hosted runtime it exists to support.
What Jinja Preserved
Using server-rendered Jinja templates with plain HTML, CSS, and JavaScript preserved the things I cared about.

Rich Hickey, in his landmark talk "Simple Made Easy," argued that simplicity is not a nicety — it is a prerequisite for reliability. That framing changed how I think about architectural decisions. When I chose Jinja, I was not choosing "less." I was choosing fewer interleaved systems — which is the actual definition of simple.news.ycombinator
First, it preserved inspectability. The beta portal is visible in the same place as the hosted route registration, auth wrapper, and readiness path. I can trace GET /beta, GET /healthz, and authenticated /mcp in one interface layer instead of context-switching across stacks and build systems.
Second, it preserved one deployable artifact. The templates and assets ship inside the Python package itself through pyproject.toml:
[tool.hatch.build.targets.wheel.force-include]
"src/policynim/assets" = "policynim/assets"
"src/policynim/templates" = "policynim/templates"That is operationally boring in the best way. One artifact. One runtime. One place to debug packaging problems. The same hosted interface layer that serves authenticated /mcp and GET /healthz also serves GET /beta, so I do not need a separate frontend deploy contract just to support the hosted beta portal.
Third, it preserved a Python-native maintenance path. The beta portal inherits the same deploy contract and the same interface discipline as the rest of the product. It is not special. It does not need a second team shape to stay healthy.
Fourth, it preserved honesty. The real complexity in PolicyNIM is not visual state management. It is grounded retrieval, auth boundaries, fail-closed behavior, and runtime health. Scope-fit architecture means keeping the surrounding UI simple enough that it does not compete with the real engineering work.
This Decision Is Backed By Tests
If this were just aesthetic preference, I would not trust it either.
Kent Beck's often-quoted sequence is make it work, make it right, make it fast. Most teams skip the middle step — "make it right" — especially on surfaces they consider secondary. "It is just a small portal" is exactly how teams end up with brittle glue code around auth and deploys.
The hosted beta portal is covered by tests/test_beta_portal.py. Those tests exercise the signed-out landing page, the GitHub OAuth start and callback flow, invalid OAuth state handling, one-time API key reveal, packaged asset serving, secure session cookies for HTTPS deployments, and rate limiting keyed by forwarded client IP.
The point of keeping this UI narrow was not to be casual. It was to keep the surface small enough that I could harden it inside the same runtime as the thing it supports. Narrow surface. Thorough tests.
When This Decision Stops Being Correct
Martin Fowler makes a point about microservices that I think applies directly to framework choices: adopting a more complex architecture before the problem demands it means paying a complexity premium before you have earned anything from it. You should grow into the complexity, not begin with it.
I would switch away from this approach if the hosted beta portal stopped being a narrow companion surface and started becoming a product surface with its own center of gravity.
That would include richer client-side state, heavier interactivity, multi-step workflows that benefit from client routing, shared UI systems across multiple surfaces, or a team structure where frontend ownership is meaningfully separate from backend and platform ownership.
At that point, a framework would probably stop multiplying complexity and start multiplying leverage.
That threshold matters. Architectural decisions should be reversible, but they should not be premature. I do not want to pay the carrying cost of a larger stack before the problem actually demands it. Richard Gabriel wrote decades ago that it’s better to start with a minimal creation and grow it as needed. That instinct is still correct. The discipline is resisting the pull to grow before you have to.
The Rule I’m Going to Reuse
Dan McKinley notes that it can be "amazing how far a small set of technology choices can go." That observation sounds modest. I think it’s actually ambitious because staying small when the temptation is to reach for something bigger takes a real, affirmative decision.
I did not avoid a framework because PolicyNIM is simple. I avoided one because this surface is narrow, and I wanted the architecture to stay honest about where the real complexity lives.
That is the rule I would reuse on any product:
Frameworks are multipliers. If the surface is large, they multiply leverage. If the surface is small, they multiply complexity.
The job is not to look modern. The job is to make the boundaries of the system match the boundaries of the problem.


