Nuxt SSR and Nitro — Handy, but Don't Let It Draw Your Boundaries
Nuxt SSR and Nitro — Handy, but Don't Let It Draw Your Boundaries
Nuxt is the Vue world’s meta-framework: routing, SSR, static generation, and a server engine all packed into one box, with Nuxt 4 having landed properly this past July. It is genuinely good — good enough that you’ll start stuffing everything into it. My attitude towards it is the same as towards most meta-frameworks: I love the convenient parts, but convenience shouldn’t get to decide architectural boundaries. This piece is about the two spots in Nuxt most easily misused — SSR data fetching, and the Nitro server layer.
SSR, static, hybrid: decide how each route should behave first
Nuxt isn’t SSR-only. Within one project, a marketing page can be prerendered to plain static and dropped on a CDN edge; a dashboard that needs live data runs SSR; some near-static content can be cached and regenerated on an interval. That per-route arrangement is hybrid rendering, specified one rule at a time through routeRules, and it’s the part of Nuxt actually worth the time to understand. Most people’s performance problems aren’t a slow framework — they’re a whole site set to SSR with no thought, recomputing pages on the server every time that could have been static.
The part that bites is data fetching. You need the right mental model for how useAsyncData and useFetch behave: the first request runs on the server, the data it fetches is serialised into the HTML and sent down with it, and when the client hydrates it picks up that payload directly rather than fetching again. It’s an elegant design, but only if you play by its rules.
Two traps come up most. One is the double fetch: you use useFetch in a component, then go and $fetch the same data again in some onMounted or watcher — the server fetches once, the client fetches again, and the user pays for a wasted round trip they can watch happen. The other, more dangerous, is leakage. A useAsyncData handler does run on the server, but written carelessly it gets carried to the client too. If that callback reads an API key straight out of the environment, or dials an internal service address directly, and the logic isn’t properly fenced into server-only territory, it can end up bundled into what ships to the browser. That isn’t Nuxt’s fault — it’s you writing server-only things onto a path that hydrates.
The rule of thumb is simple: can the source of that data see daylight? If it can — a public API, JSON on a CDN — put it wherever. If it can’t — internal addresses, secrets, privileged queries — it stays on the server, and the front end only ever receives the already-trimmed result.
Nitro as a BFF is smooth, but smooth isn’t a boundary
Nitro is the server engine underneath Nuxt; the server routes you create under server/api/ run on it. It can do more than people assume: the same layer is both the SSR data source and a lightweight BFF gateway. When the front end wants data, it doesn’t call a third party directly — it calls its own Nitro route, and that layer reaches downstream, adds the auth, and decides the response shape. External requests never touch the data layer directly. I’ve run this setup, and it’s genuinely convenient: secrets stay on the server, the call chain is centralised, and the front end only handles clean data.
The trouble isn’t that it can’t do the job — it’s that it does the job so easily that the boundary starts to smear. The framework’s server layer carries a structural temptation: it can see both the browser-facing aggregation and the backend-facing risk, and both get written into the same directory, the same kind of defineEventHandler. Before long you can’t tell which route is trimming data for the front end and which is carrying access to an internal service. One server/api/ folder mixing “assemble the fields the lobby needs” with “query the table carrying DB credentials directly” makes it hard for a reviewer to see at a glance where the risk lands.
So I still lean towards a separate BFF. Not because Nitro can’t carry that role — for a small project, a team of a few people, where you can hold that line in your head, using Nitro as the BFF is perfectly reasonable and saves you a service. But once a system starts to grow, I pull the backend-facing, risk-carrying layer out, and let Nitro go back to what it’s best at: SSR and lightweight aggregation for the front end. Splitting them isn’t about adding a layer — it’s so the line between who faces the browser and who faces the backend is decided by the architecture, not by “well, it was handiest to write it here”.
Nuxt makes a lot of things simple, and that’s its worth. But simple and bounded are two different things. It makes it possible to cram aggregation, secrets, and downstream access all into a server route; that doesn’t make it advisable. Convenience is a gift the tool hands you; the boundary is a discipline you keep yourself. A green build has never once meant a sound design.
Key takeaways
- Nuxt’s hybrid rendering uses
routeRulesto set SSR / static / cache per route; most performance problems come from setting the whole site to SSR with no thought. useAsyncData/useFetchfetch on the server first and hand off to the client after hydration; get it wrong and you double fetch or carry a server-only secret into the client bundle.- Deciding where data belongs is one question: can its source see daylight? If not, it stays on the server.
- A Nitro server route can be both the SSR source and a lightweight BFF, with external requests never touching the data layer directly — a convenient setup.
- But convenience shouldn’t draw architectural boundaries; once a system grows, pull the backend-facing, risk-carrying layer out rather than mixing it with browser-facing aggregation in one folder.
Sheng’s take, drafted with Claude · part of the 2026-06-13 blog renovation, paint still drying.