First in a series about migrating from legacy web architectures to a modern Nuxt 4 stack. The series covers architecture, code generation, performance, infrastructure, and the automation philosophy behind every decision.
The Starting Point: Architectures That Work — But Barely
Across multiple large, high-traffic, customer-facing web platforms — the kind where millions of visitors per year browse products, configure options, and complete purchases — two architectural patterns show up again and again:
- Dual-rendering: ASP.NET MVC with Razor views and Vue.js components layered on top. The server renders HTML; the client re-renders parts of it.
- SPA + BFF: A single-page application handles all rendering, calling a backend-for-frontend (BFF) layer for data. Types, REST clients, and mapping code are written by hand on both sides.
Both patterns work. Pages load, forms submit, orders go through. But both are collapsing under complexity that better code cannot fix. The problems are structural — rooted in manual synchronization between client and server code that compounds with every new feature.
This article walks through the patterns repeatedly seen in these systems, why they decay, and what finally pushes teams to break out.
The Architecture That Grew Sideways
Variant A: Dual Rendering (Server HTML + Client JS)
The most visible version of this problem follows a pattern that sounds sensible at first: server-side HTML rendering through Razor templates, with client-side interactivity layered in via Vue.js components.
The server renders page structure and static content. Vue components handle the dynamic pieces — a product configurator, a checkout form, an interactive filter. The two worlds are stitched together with data attributes, JSON blobs injected into the Razor HTML, and an ever-growing pile of initialization scripts.
Here is a simplified view of how a single page request flows through such a system:
flowchart TB
subgraph B["Browser"]
Breq["User requests page"]
end
subgraph S["ASP.NET MVC Server"]
C["Controller"]
VM["C# ViewModel"]
RZ["Razor View"]
HTML["Server-rendered HTML<br/>+ embedded JSON blobs<br/>+ <script> tags mounting Vue"]
end
subgraph CS["Client-Side (Browser)"]
PHTML["Step 1: Parse large HTML document"]
PJS["Step 2: Download all JS bundles (no splitting)"]
PJSON["Step 3: Parse JSON blobs from data attributes"]
MOUNT["Step 4: Mount Vue components onto DOM nodes"]
RERENDER["Step 5: Re-render with client-side data"]
CLS["→ Content jumps (CLS)"]
TBT["→ Slow interaction (TBT)"]
end
Breq --> C --> VM --> RZ --> HTML
HTML --> PHTML --> PJS --> PJSON --> MOUNT --> RERENDER
RERENDER --> CLS
RERENDER --> TBT
This is a dual-rendering architecture: the server renders HTML, and the client re-renders parts of it. Neither side has the full picture. The gap between what the server knows and what the client knows is where the bugs live.
Variant B: SPA + BFF (Client Renders Everything, Server Provides Data)
The second variant looks more modern on the surface. A single-page application — React, Angular, or Vue — handles all rendering. The server exposes REST APIs (often through a BFF layer), and the client fetches data via HTTP.
There is no dual rendering, no hydration gap, no content flash. But the manual synchronization problem is identical:
- Every API endpoint needs a corresponding TypeScript interface on the client
- Every DTO in C# or Java needs a hand-written model in TypeScript
- Every REST client is bespoke: custom fetch wrappers, error handling, retry logic, response parsing
- Every new field or renamed property requires coordinated changes across both codebases
- Every API version bump triggers a manual update cascade through the frontend
flowchart LR
subgraph SPA["SPA (React/Angular/Vue)"]
UI["Components"]
TSM["Hand-written TS models"]
RC["Hand-written REST clients"]
end
subgraph BFF["BFF / Backend"]
CTRL["Controllers"]
DTO["DTOs / ViewModels"]
SVC["Services"]
end
UI --> TSM --> RC --> CTRL --> DTO --> SVC
classDef warn fill:#fff2e6,stroke:#e67e22,stroke-width:1px,color:#000;
TSM:::warn
RC:::warn
DTO:::warn
The rendering is clean, but the data contract layer — the types, the clients, the mapping — is just as manual and just as fragile as in the dual-rendering case. A renamed field in the backend does not produce a compile error in the frontend. It produces a undefined at runtime, caught by QA if you are lucky, or by a customer if you are not.
Teams in this position often generate TypeScript clients from OpenAPI specs, which helps. But it only covers the REST surface. If the frontend also consumes a headless CMS, a SOAP service, or multiple microservices with their own contracts, those sources still need manual type definitions. The problem is reduced but not eliminated — and the REST clients themselves still carry per-endpoint boilerplate: headers, auth tokens, error mapping, pagination handling, cache invalidation.
Both variants converge on the same core issue: manual synchronization of data contracts across a network boundary, at a scale where humans cannot keep up.
Five Problems That Compound
The following problems manifest in both architectural variants, though the first (the hydration gap) is specific to dual-rendering systems. The remaining four apply equally to SPA + BFF architectures.
1. The Hydration Gap
Modern SSR frameworks like Nuxt or Next.js solve the “server renders, client takes over” problem with hydration: the client receives the exact same component tree the server rendered and attaches to it without re-rendering. The DOM stays stable. The user sees no flicker.
A dual-rendering architecture has no such mechanism. Vue components mount after the server-rendered HTML is already visible. The browser shows the Razor output first — then, once JavaScript loads and runs, Vue replaces sections of the DOM with its own rendered output. The result is visible content jumps (Cumulative Layout Shift) and a page that feels slow even when the server responds quickly.
In practice, this often looks like a user landing on a product comparison page and seeing a static HTML shell for 1–2 seconds, followed by a jarring re-render as Vue components initialize and inject dynamic content. On mobile devices with slower CPUs, the effect is even worse.
sequenceDiagram
participant U as User
participant Br as Browser
participant S as ASP.NET MVC + Razor
participant JS as Vue JS Runtime
U->>Br: Navigate to product comparison page
Br->>S: HTTP GET /products/compare
S-->>Br: HTML (Razor-rendered shell<br/>+ JSON blobs + script tags)
Br-->>U: Initial static HTML rendered<br/>(*visible to user*)
par Network & CPU
Br->>JS: Download & parse JS bundles
JS->>Br: Initialize Vue runtime
JS->>Br: Mount Vue components<br/>and re-render sections
end
Br-->>U: DOM updates cause<br/>content jumps (CLS) and delay<br/>before interactive (TBT)
2. The Data Translation Layer
In these architectures, every piece of data that must appear in both server-rendered HTML and client-side Vue components has to be manually translated between two type systems:
C# ViewModel (Server) TypeScript Model (Client)
───────────────────── ──────────────────────────
ProductOfferModel.cs → ProductOffer.ts
.PriceGross (decimal) .priceGross (number)
.ProductName (string) .productName (string)
.ValidFrom (DateTime) .validFrom (string)
.IsDefault (bool) .isDefault (boolean)
For every backend data model, a developer has to:
- Write a C# ViewModel class
- Write a corresponding TypeScript interface
- Write a mapping function that transforms the C# object into JSON
- Write another mapping function that deserializes it on the client side
- Keep all four in sync whenever any field changes
This translation layer is tedious, but the real problem is that it creates constant bugs. A renamed field in C# silently breaks the client. A new enum value triggers a runtime error in JavaScript. A date format change causes forms to display the wrong values.
Some teams address this with OpenAPI (Swagger) by generating TypeScript clients from the backend’s REST API spec. For pure REST architectures, that is a solid approach. But it only covers the REST surface. If the frontend also consumes a headless CMS, a SOAP service, or server-rendered JSON blobs, those sources still need their own manual type definitions. OpenAPI reduces the problem; it does not eliminate it.
You end up with dozens of model pairs, each one a potential point of divergence.
flowchart LR
subgraph Server["Server (C#)"]
VM["ViewModel class<br/>(e.g., ProductOfferModel.cs)"]
MAP_S["Mapping function:<br/>C# → JSON"]
end
subgraph Wire["Wire Format"]
JSON["JSON payload"]
end
subgraph Client["Client (TypeScript/Vue)"]
TSIF["TypeScript interface<br/>(ProductOffer.ts)"]
MAP_C["Mapping/parsing function:<br/>JSON → TS model"]
VC["Vue component"]
end
VM --> MAP_S --> JSON --> MAP_C --> TSIF --> VC
classDef warn fill:#fff2e6,stroke:#e67e22,stroke-width:1px,color:#000;
VM:::warn
TSIF:::warn
MAP_S:::warn
MAP_C:::warn
3. No Unified API
In legacy systems that grew organically, the frontend usually does not talk to a single API. Instead, it calls:
- REST endpoints for business logic and transactional operations
- Direct headless CMS REST APIs for marketing or content pages
- Server-rendered JSON blobs for other server-controlled content
- Various cloud service endpoints for user data, feature flags, or personalization
Each integration has its own error handling, retry logic, response format, and TypeScript types. There is no shared understanding of what “calling the backend” even means.
The result: every new feature requires figuring out which endpoints to call, in what order, and how to stitch the responses together. Frontend developers spend more time reading backend code than writing frontend code.
flowchart LR
FE["Frontend app<br/>(Vue in Razor pages)"]
subgraph APIs["Backend & Services"]
REST["Business REST API"]
CMS["Headless CMS REST API"]
JSONB["Server-rendered JSON blobs"]
CLOUD["Cloud services<br/>(flags, personalization, auth, etc.)"]
end
FE --> REST
FE --> CMS
FE --> JSONB
FE --> CLOUD
4. Performance as an Afterthought
Performance in these systems is rarely designed in — it is patched over with operational over-provisioning. The usual pattern looks like this:
| Metric | Value |
|---|---|
| Median response time (homepage) | 2,618 ms |
| Error rate under normal load | 3.91% |
| JavaScript bundle size | No code splitting — everything loaded upfront |
| Infrastructure | 3× P3v3 App Service instances (24 vCPU, 96 GB RAM) |
| Scaling model | Fixed — always on, always paying |
A 2.6-second median response time is usually not a server problem — the C# backend is often reasonably fast. The problem is the rendering pipeline: large HTML pages, no code splitting, no lazy loading, uncompressed assets, and the sequential load-parse-mount-re-render cycle Vue must perform on every page view.
Throwing hardware at the problem — and three P3v3 instances is a lot of compute — keeps the site alive, but it does not make it fast. And you pay the same at 3 AM with zero traffic as you do at noon during peak load.
5. Developer Experience Death by a Thousand Cuts
Adding a new content section — say, a promotional banner with a countdown timer — can require touching six distinct layers:
- C# TagHelper — to define how the server renders the banner’s outer HTML
- C# ViewComponent — to load data for the banner from a service
- CSHTML Razor View — to define the server-side template
- C# ViewModel — to define the data shape
- TypeScript Model — to mirror the data shape for the client
- Vue Component — to make the banner interactive (countdown logic)
Six files, three languages, two rendering contexts. If the banner needs data from a headless CMS, add another REST client and another model conversion. If it needs form validation, add both C# server-side validation and JavaScript client-side validation — two implementations of the same rules.
In a SPA + BFF architecture, the first three layers disappear — but the remaining three do not. You still write the C# DTO, the TypeScript mirror, and the Vue component. You still write a REST client method. You still keep both sides in sync. The layer count drops from six to four, but the synchronization tax remains identical. Every field change still crosses the wire boundary by hand.
That is the cost of every feature. It compounds, and after a while developers start dreading change altogether.
flowchart TB
FEAT["Feature: Promotional banner<br/>with countdown timer"]
TAG["C# TagHelper"]
VCMP["C# ViewComponent"]
RAZOR["CSHTML Razor View"]
VM["C# ViewModel"]
TS["TypeScript model"]
VUE["Vue component<br/>(countdown logic)"]
FEAT --> TAG
FEAT --> VCMP
FEAT --> RAZOR
FEAT --> VM
FEAT --> TS
FEAT --> VUE
The Deeper Problem: Architectural Coupling Disguised as Separation
The irony is that these architectures look like they separate concerns. Server-side rendering is “separated” from client-side rendering. C# is “separated” from TypeScript. Razor views are “separated” from Vue components.
But the separation is structural, not functional. The layers remain tightly coupled through shared data contracts, rendering assumptions, and behavior expectations. Change one layer, and the ripple effects cross every boundary.
flowchart LR
subgraph Server["Server"]
CS["C# Models"]
RZ["Razor Views"]
end
subgraph Client["Client"]
VUE["Vue Components"]
end
CS --> RZ
RZ --> VUE
subgraph Coupling["Shared assumptions / tight coupling"]
FN["Field names must match"]
DF["Date formats must match"]
EV["Enum values must match"]
NH["Null handling must match"]
end
CS --- Coupling
RZ --- Coupling
VUE --- Coupling
Real separation of concerns means a change in one area does not force changes in another. These architectures fail that test badly.
Breaking Out: The Architecture Used Instead
The replacement architecture follows a different principle: one rendering engine, one type system, one unified API surface.
flowchart LR
subgraph Browser["Browser"]
B["User"]
end
subgraph Nuxt["Nuxt 4 (SSR)"]
V3["Vue 3<br/>Component Tree"]
APClient["Apollo GraphQL Client"]
end
subgraph Gateway["Apollo Server<br/>(GraphQL Gateway)"]
end
subgraph Backends["Backend Subgraphs"]
CMS["CMS Subgraph"]
DOTNET[".NET Backend Subgraph"]
end
B <-->|HTTP| Nuxt
Nuxt --> V3
V3 --> APClient
APClient --> Gateway
Gateway --> CMS
Gateway --> DOTNET
The key shifts:
- Single rendering engine: Vue 3 renders on both server and client. The server produces HTML; the client hydrates the same component tree. No content jumps, no flickering.
- Single API surface: A GraphQL gateway stitches together the headless CMS and the .NET backend into one schema. The frontend writes
.graphqlquery files; code generation produces fully typed TypeScript composables. No manual model translation. - Single type system: TypeScript everywhere, with types generated from the GraphQL schema. A renamed field in the backend breaks the TypeScript compiler — not the production website at 2 AM.
sequenceDiagram
participant U as User (Browser)
participant N as Nuxt 4 SSR (Vue 3)
participant G as GraphQL Gateway
participant C as CMS Subgraph
participant D as .NET Subgraph
U->>N: HTTP GET /page
N->>G: GraphQL query<br/>(content + business data)
G->>C: Resolve CMS fields
C-->>G: CMS data
G->>D: Resolve .NET fields
D-->>G: .NET data
G-->>N: Unified GraphQL response
N-->>U: HTML (SSR) with embedded state
U->>N: Hydration request (client-side)
N-->>U: Interactive Vue app<br/>(no re-render jump)
What Gets Eliminated
One of the most satisfying aspects of a migration like this is what you get to delete:
| Layer | Old System | New System |
|---|---|---|
| C# ViewModels | One per content type | ❌ Eliminated — types generated from GraphQL |
| C# TagHelpers | Custom HTML rendering | ❌ Eliminated — Vue components render everything |
| CSHTML Razor Views | Server-side templates | ❌ Eliminated — replaced by Vue SFC templates |
| TypeScript model files | Manual mirror of C# types | ❌ Eliminated — generated from schema |
| REST client wrappers | Per-endpoint fetch code | ❌ Eliminated — generated GraphQL composables |
| JSON data injection | Script tags with serialized models | ❌ Eliminated — SSR payload hydration |
| Dual validation | C# + JS validation rules | ❌ Eliminated — backend validation rules fetched at runtime |
Every row in that table represents hundreds of lines of code and hundreds of potential bugs — gone, not refactored.
flowchart TB
subgraph Legacy["Legacy layers"]
L1["C# ViewModels"]
L2["TagHelpers"]
L3["CSHTML Razor Views"]
L4["Manual TS models"]
L5["REST client wrappers"]
L6["JSON data injection"]
L7["Dual validation logic"]
end
subgraph New["New stack"]
GQL["GraphQL schema"]
CGEN["Code generation<br/>(types + composables)"]
VUE3["Vue 3 components<br/>(SSR + CSR)"]
end
L1 -.removed.-> GQL
L2 -.removed.-> VUE3
L3 -.removed.-> VUE3
L4 -.removed.-> CGEN
L5 -.removed.-> CGEN
L6 -.removed.-> VUE3
L7 -.moved.-> GQL
GQL --> CGEN --> VUE3
Measured Results
Architecture decisions are opinions until you measure them. After migrating a legacy ASP.NET MVC + Razor + Vue system to Nuxt 4 with a GraphQL gateway and load testing against production-equivalent traffic patterns:
| Metric | Legacy System | New System | Change |
|---|---|---|---|
| Median response time | 2,618 ms | 165 ms | 15.9× faster |
| Error rate (1× prod load) | 3.91% | 0.09% | 97% lower |
| Max capacity | ~99 RPM | 494+ RPM | 5× more |
| Infrastructure | 3× P3v3 fixed (24 vCPU, 96 GB) | Auto-scaled containers | Elastic |
| Lighthouse Performance | ~50 | 97+ (mobile) | Near-perfect |
| Developer layers per feature | 6 files, 3 languages | 1–2 files, 1 language | 80% less |
A 2.6-second median means the better half of requests still took 2.6 seconds — the slower half took even longer. A 165 ms median means the page is rendered before a user can blink. At 5× production load (494 RPM), the new system held that 165 ms median flat. It did not degrade.
flowchart LR
subgraph Legacy["Legacy System"]
LRt["Median response: 2,618 ms"]
LEr["Error rate: 3.91%"]
LCap["Capacity: ~99 RPM"]
LInfra["3× P3v3, fixed"]
end
subgraph New["New System"]
NRt["Median response: 165 ms"]
NEr["Error rate: 0.09%"]
NCap["Capacity: 494+ RPM"]
NInfra["Auto-scaled containers"]
end
Legacy --> New
Lessons Learned
Manual synchronization is the real enemy
Whether you have a dual-rendering architecture (server HTML + client re-render) or a clean SPA that calls a BFF, the core problem is the same: humans maintaining parallel data contracts across a network boundary. In the dual-rendering case, you additionally suffer from rendering conflicts and layout shifts. But the SPA + BFF case is no less painful in the long run — every endpoint still demands a hand-written TypeScript interface, a bespoke REST client, manual error handling, and a quiet prayer that nobody renamed a field on the other side.
If you are considering “progressive enhancement” by layering a JavaScript framework on top of server-rendered templates, be deliberate about the boundary. And if you already have a clean SPA but find yourself maintaining dozens of REST client wrappers and hand-mirrored types, recognize that the problem is not the rendering model — it is the absence of a single source of truth for data contracts that both sides can consume automatically.
Manual type translation does not scale
Keeping C# models and TypeScript interfaces in sync is manageable at 5 types. At 50, it becomes a full-time job. At 100+, it is impossible without tooling. This applies equally whether those types back Razor views or REST endpoints consumed by a SPA — the synchronization burden is the same.
OpenAPI-based code generation bridges the gap for REST APIs, and many teams use it successfully. But it only covers the REST surface. If the frontend also consumes a headless CMS, a SOAP service, or multiple microservices with their own contracts, those sources still need manual type definitions. And even with OpenAPI generation, the REST clients themselves often carry per-endpoint boilerplate: custom headers, auth token injection, error mapping, pagination, and cache invalidation logic that gets copy-pasted across dozens of service wrappers.
A single schema that unifies all sources (for example, GraphQL) lets you generate types and clients for everything from one place. That is the difference between reducing boilerplate and eliminating it.
You cannot buy your way out of a slow architecture
Three P3v3 instances (24 vCPU, 96 GB RAM) running 24/7 cannot match the performance of a few auto-scaled container replicas running a properly designed architecture. The response time improvement was not 10% or 20% — it was 15.9×. That kind of gain does not come from tuning. It comes from removing fundamental bottlenecks in how requests are processed.
Infrastructure cost follows architecture
Legacy systems tend to rely on fixed, always-on compute that costs the same regardless of traffic. A well-designed replacement auto-scales: 5 replicas at idle, 15 under load. Fixed infrastructure is either over-provisioned — wasting money — or under-provisioned — dropping requests. Elastic infrastructure tracks actual demand.
What’s Next
This article covered the what and why. The next article provides the architectural overview — the big picture that frames every subsequent deep dive:
- Article 2: The Target Architecture — A Bird’s-Eye View — The four-container topology, the in-process GraphQL gateway, the code generation pipeline, and how every article in the series maps to the architecture.
- Article 3: GraphQL Schema Stitching — One API to Rule Them All — How to unify multiple backend data sources into a single GraphQL gateway, including a custom
@delegatedirective for cross-subgraph field resolution. - Article 4: The @delegate Directive Deep Dive — Cross-subgraph field resolution with typed placeholders and formatters.
- Article 5: GraphQL-Based Code Generation — Eliminating All Boilerplate — How writing a
.graphqlfile generates a fully typed Vue composable with zero manual work.
Each article focuses on a specific pattern and shows how automation can eliminate entire categories of manual work.
Munir Husseini is a software architect specializing in full-stack TypeScript, .NET, and cloud-native architectures.





