The Cost of Imposing Architecture on Opinionated Frameworks

The Cost of Imposing Architecture on Opinionated Frameworks
Photo by Richard Horvath / Unsplash

Two hundred lines of authentication glue code. Then the edge cases started appearing. Then the race conditions from Next.js's prefetching. I was writing more code to maintain the separation between my backend and frontend than I was writing to solve actual problems.

Something was wrong.

I come from the PHP world where MVC monoliths are the norm. When I decided to explore the TypeScript ecosystem seriously, I wanted to do it properly. Modern architecture. Clean separation. A backend-for-frontend pattern with Hono handling the API layer, Next.js handling the presentation layer, each with clear responsibilities. The setup looked clean on paper. Hono app with database access, all business logic living there. Next.js app for the UI, making requests to the backend. Stateless, proper boundaries, the kind of architecture that looks good in diagrams.

Then I tried to implement authentication.

The obvious path was JWT-based auth. Issue tokens on login, verify them on each request, keep everything stateless. Standard practice. The backend would handle token generation and validation. The frontend would just carry the token around. Except Next.js doesn't make this simple.

I needed custom middleware to validate JWTs from cookies. Logic to detect when tokens were nearing expiration. A refresh flow that wouldn't cause race conditions with Next.js's aggressive prefetching. Protection for server-side rendered routes. Different handling for client-side navigation versus initial page loads. The helper file kept growing. Edge cases kept surfacing. What about the separation between protected and unprotected routes? What about different auth requirements for different sections? What about the interaction between Next.js's edge runtime and my token validation logic?

A few hours into writing this, I started asking "what if" questions. What if the network fails mid-refresh? What if prefetching triggers token validation before the user actually navigates? What if server and client components have different session states? The complexity was stacking up fast, and I could see I'd never make this bulletproof.

I tried a different approach. Session-based auth with the backend managing state. Forward cookies from Next.js to the backend on every request. More glue code. More edge cases. Cookie forwarding isn't straightforward when you're dealing with Next.js's rendering model. Prefetching requests needed different cookie handling than user-initiated requests. Server Components and Client Components needed different auth flows.

At some point I stepped back and asked a simple question: what benefit am I getting from this separation?

The answer was uncomfortable.

I was maintaining architectural purity. That was it. The separation gave me a clean diagram and a sense that I was doing things "the modern way." But it wasn't solving any actual problem I had. It was creating problems.

Next.js has Route Handlers. It has Server Actions. It has built-in patterns for mixing server-side and client-side logic. The framework is designed to be full-stack. I was fighting that design to impose a pattern the framework actively resists. I was building infrastructure to maintain a separation the framework had already solved.

The shift happened gradually. I already had a monorepo set up to share validation schemas and type definitions between the apps. Why not extract the business logic into a shared package? Not just validation - the actual operations. Complex data fetching patterns. Business rules enforcement. The Prisma schema and client. Everything that defines how the application works with data.

Both the Next.js app and the Hono backend could import from this shared package. If the app and the public API need the same paginated view, they'd use the same code. No duplication. No version drift. When something changes, it changes in one place.

That meant giving Next.js direct database access through the shared package. Which meant I could wire up Better Auth directly to Prisma and let it handle sessions, tokens, and all the complexity I'd been drowning in.

The Hono backend stayed in the monorepo for external-facing needs - public APIs, webhook integrations, anything where Next.js's opinions don't fit. But the internal application logic stopped depending on network boundaries to enforce separation.

The authentication nightmare disappeared.

Better Auth integrated cleanly. No glue code. No custom middleware juggling tokens. No race conditions from prefetching. The framework's built-in patterns just worked because I stopped fighting them.

The workflow changed too. Before, I'd think on two levels - UI on one side, API endpoints on the other. I'd build the backend endpoints, then connect them in Next.js, then realize I needed more data, then go back to the backend, then back to the frontend. A constant cycle of context switching between two codebases.

Now it's seamless. Server Actions that interact with the database directly. Call them from pages or Client Components. The IDE understands the whole flow - autocomplete works, type checking catches issues, refactoring touches everything that needs to change. One level of thinking instead of two.

The trade-off is real, though. The boundaries are murkier now.

When the network enforced separation, the decision was automatic - business logic goes in the backend, period. Now it's judgment. Does this new feature belong in the shared package because other apps might use it? Or does it stay in Next.js because it's specific to the app? Sometimes you're prototyping in Next.js and you don't know yet if it should be shared. Do you extract it prematurely or deal with moving it later?

These are softer boundaries. They require team agreement rather than architectural enforcement. You maintain separation through discipline and guidelines, not through hard barriers that make the wrong choice impossible.

But the complexity dropped dramatically. I'm not writing code to preserve an architectural boundary. I'm writing code to solve problems. The authentication works reliably. The developer experience is smooth. I'm using the framework the way it was designed to be used.

This pattern shows up more often than we admit. Call it the Framework Resistance Tax - the cost you pay when you impose your preferred architecture on a framework designed for a different one. The cost shows up as glue code, workarounds, and constant friction with the framework's conventions.

The interesting part is recognizing when you're paying that tax.

Sometimes the separation is worth it. If you're building a platform with public APIs, multiple client types, or separate team ownership of frontend and backend, the boundary has a job to do. If you need to swap out the frontend later or you're working in a polyglot environment, the separation earns its complexity.

But if you're building with Next.js as your full stack and you find yourself writing mountains of code just to maintain a clean separation, it's worth asking what problem that separation is solving. If the answer is "architectural purity," you might be fighting a battle that doesn't need to be fought.

Frameworks have opinions. When those opinions align with how you think about problems, embracing them makes you more productive. When they don't, you're better off picking a different framework than spending weeks building elaborate workarounds. I'm not advocating for monoliths. I'm advocating for recognizing when your tools are pulling one direction and you're pulling another, and honestly evaluating whether the fight is worth it.

Sometimes the "improper" architecture is the one that lets you stop writing glue code and start shipping features. In my case, Next.js's full-stack philosophy turned out to match how I naturally think about building applications. Your mileage will vary. The point is to notice when you're swimming upstream and ask yourself why.