Using Server Routes, Composables and Nitro the Right Way
Nuxt 3 is more than a Vue 3 framework with file-based routing. The Nitro engine, server routes, auto-import, and the clean separation of server and client layers form a deliberate architecture model, one that only reveals its strengths once you understand why it is built the way it is.
Table of Contents
- 1. The three layers of Nuxt 3 architecture
- 2. Nitro: the universal server behind Nuxt 3
- 3. Server routes: a lightweight API layer in /server/api
- 4. Composables and auto-import: how Nuxt 3 solves it
- 5. useFetch vs. $fetch: which tool for which job?
- 6. Middleware in Nuxt 3: route guards and server middleware
- 7. Plugins and the app lifecycle in Nuxt 3
- 8. Server-only vs. client-only code: .server.ts and .client.ts
- 9. Nuxt 3 rendering modes compared
- 10. Summary
- 11. FAQ
1. The three layers of Nuxt 3 architecture
Anyone who thinks of Nuxt 3 as merely "Vue 3 with file-based routing" underestimates what the framework actually delivers. Its design follows a clear layered model, where each layer has its own runtime environment, its own APIs, and its own responsibilities. This separation is no accident, it is a deliberate architectural decision, one that lets Nuxt 3 run the same codebase on a classic Node.js server, on serverless functions, and on edge runtimes such as Cloudflare Workers.
The Nuxt 3 architecture consists of three clearly separated layers, each carrying its own responsibilities. The first layer is the Nitro engine, the universal server that sets Nuxt 3 apart from a plain Vue 3 framework. The second layer is the Vue 3 client application, pages, layouts, components, and composables that render in the browser and are pre-rendered via SSR. The third layer is the build layer, Vite as the development server and Rollup as the production bundler, coordinated by Nuxt itself.
Each of these three layers has a strictly bounded runtime environment: Nitro knows no DOM, Vue components must not access databases directly, and the build layer is completely invisible at runtime. Knowing and respecting these boundaries is the basic prerequisite for a maintainable Nuxt 3 architecture.
Understanding these three layers is essential for choosing the right Nuxt 3 tools for each task. Database access, API authentication, and sensitive business logic belong in the Nitro layer, in server routes and server middleware. Interactive UI components, reactive state, and user interactions belong in the Vue layer. Build configuration, modules, and layer extensions belong in the build layer. Placing code in the wrong layer, for example accessing a database directly in a Vue component without a server route, produces security holes or code that fails in the SSR context.
2. Nitro: the universal server behind Nuxt 3
Nitro is the centerpiece of the Nuxt 3 architecture and, at the same time, the least understood element. Nitro is a standalone framework for server-side applications built on top of the H3 micro-framework. It compiles the server code into a single, deployable bundle that runs on a variety of platforms: Node.js, edge runtimes such as Cloudflare Workers, Vercel Edge, Netlify Functions, and more. This universal deployment model is why a Nuxt 3 application can move from a classic VPS to a CDN edge network without any configuration changes.
Nitro provides built-in caching with configurable cache strategies, an event system based on H3, server-side rendering, and the execution of server routes. The Nitro cache system is particularly powerful: server routes can cache their responses with a single line of configuration, by time, by request parameter, or by custom key. This caching happens on the server before the request even reaches the Vue application, and it drastically reduces server load for frequently requested data. The next section shows how server routes are built in practice.
3. Server routes: a lightweight API layer in /server/api
Nuxt 3 server routes are handler functions in the /server/api/ and /server/routes/ directories that automatically become API endpoints. They run exclusively on the server, have access to the Nuxt server configuration, and can access databases, external APIs, and environment variables directly, without the client code ever seeing the access token. That is the fundamental security advantage over calling APIs directly from the client.
The file-to-route convention of Nuxt 3 server routes is intuitive: /server/api/products.get.ts becomes GET /api/products, /server/api/products.post.ts becomes POST /api/products, and /server/api/products/[id].ts becomes /api/products/:id for all HTTP methods. The suffix in the filename (.get, .post, .put, .delete) restricts the handler to a single HTTP method. Handlers without a suffix accept all methods and can differentiate internally with getMethod(event).
// server/api/products/[id].get.ts
// Nuxt 3 Server Route, runs on server only, never in the browser bundle
import { z } from 'zod'
// Define route-level cache: response cached for 5 minutes by product ID
export default defineCachedEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
// Input validation with zod, rejects invalid IDs before DB query
const schema = z.string().uuid()
const parsed = schema.safeParse(id)
if (!parsed.success) {
throw createError({ statusCode: 400, message: 'Invalid product ID format' })
}
// DB access, safe here because this code never runs in the browser
const product = await db.products.findUnique({ where: { id: parsed.data } })
if (!product) {
throw createError({ statusCode: 404, message: 'Product not found' })
}
return product
}, {
maxAge: 60 * 5, // cache for 5 minutes
getKey: (event) => `product:${getRouterParam(event, 'id')}`,
})
4. Composables and auto-import: how Nuxt 3 solves it
The auto-import system in Nuxt 3 is one of its most controversial features, and one of its most thoughtfully designed, once you understand how it works. Files in /composables/, /utils/, and /components/ are automatically imported into every component scope without explicit import statements being required. Nuxt statically analyzes the code at build time and inserts the missing imports automatically. That means no IDE confusion, no hidden global state, only compile-time-generated imports.
Custom composables in /composables/ are also auto-imported and can extend or wrap the built-in Nuxt 3 composables such as useFetch, useState, and useRouter. The difference compared to Vue composables outside of Nuxt: Nuxt composables have access to the SSR context and can connect server-side data with client-side hydration state. A useProducts composable that internally uses useFetch works both on the initial server render and on subsequent client-side navigations, without code duplication or special cases.
// composables/useProducts.ts
// Auto-imported by Nuxt, works on server (SSR) and client (navigation)
export function useProducts(categoryId?: Ref<string>) {
// useFetch is SSR-aware: fetches on server for initial render,
// then uses cached data for client-side hydration
const { data: products, status, error, refresh } = useFetch(
() => `/api/products${categoryId?.value ? `?category=${categoryId.value}` : ''}`,
{
key: () => `products-${categoryId?.value ?? 'all'}`, // deduplicate requests with same key
watch: [categoryId], // auto-refetch when categoryId changes
transform: (data) => data.map(normalizeProduct), // transform before caching
}
)
const isLoading = computed(() => status.value === 'pending')
const isEmpty = computed(() => !isLoading.value && products.value?.length === 0)
return { products, isLoading, isEmpty, error, refresh }
}
5. useFetch vs. $fetch: which tool for which job?
The most common point of confusion in the Nuxt 3 architecture: when to use useFetch, when useAsyncData, when $fetch? The answer depends on context. useFetch is the composition of useAsyncData and $fetch, it combines data fetching, caching, SSR hydration, and reactive URL binding in one call. It is the right choice for data that is needed on the initial page render and should be part of the SSR output.
$fetch is the raw ofetch-based HTTP client object without SSR awareness. It is the right choice for programmatic API calls that are not triggered during the page render, button clicks, form submissions, mutations. Using useFetch inside a button click handler is a common mistake: it creates a reactive binding that is not automatically cancelled on component unmount, and it shares the request state globally with every instance that uses the same key. For imperative API calls, $fetch is the semantically correct tool.
6. Middleware in Nuxt 3: route guards and server middleware
Nuxt 3 has two distinct middleware concepts that are often confused. Route middleware runs in the client router, JavaScript functions in /middleware/ that execute before every navigation. They serve as route guards: checking authentication, performing redirects, sending analytics events. Server middleware runs inside the Nitro engine, functions in /server/middleware/ that intercept every HTTP request before it reaches a handler. They serve for CORS headers, rate limiting, request logging, and JWT validation.
The decisive difference: route middleware has access to Nuxt state and the Vue router but only runs in the browser (except for the server-rendered initial request). Server middleware has access to the full HTTP request and all server resources but runs completely independently of the Vue client code. Authentication logic based on cookies or JWT tokens belongs in server middleware, there it cannot be bypassed by the client. Route middleware is a second layer for UX feedback, not the only line of defense.
7. Plugins and the app lifecycle in Nuxt 3
Nuxt 3 plugins in /plugins/ are executed automatically at app startup and can extend the nuxtApp context. They are the right tool for initializing global libraries, registering directives, and setting up global state providers. The suffix .server.ts restricts a plugin to the server render, .client.ts to the browser. Without a suffix, the plugin runs on both sides.
An important detail of the Nuxt 3 architecture: plugins can be async. An async plugin pauses the Nuxt startup process until the plugin has resolved. That makes sense for plugins that need to load configuration or initialize external services. But it delays the first render, async plugins should be kept as short as possible and should not contain network requests that could just as well be loaded later.
8. Server-only vs. client-only code: .server.ts and .client.ts
The suffix system Nuxt 3 uses for server-only and client-only files is an elegant architectural pattern. A file named analytics.client.ts is only included in the client bundle, it does not fail on the server because it simply does not exist there. A file named dbClient.server.ts never appears in the client bundle, the database code and all its imports are physically excluded. This prevents bundle bloat from server-only libraries and prevents sensitive server code from being inspectable in the browser.
The same pattern applies to components: a HeavyChart.client.vue component is only rendered in the browser, never on the server. The server delivers a placeholder that the browser replaces after hydration. That is the recommended solution for components that need browser APIs (window, document, Canvas), instead of the common mistake of manually wrapping window access with if (process.client).
9. Nuxt 3 rendering modes compared
Nuxt 3 supports several rendering modes that can be configured per route, a feature that makes the Nuxt 3 architecture flexible enough for hybrid applications. The following table shows the differences and when each mode makes sense.
| Mode | Nuxt configuration | Use case | Caching |
|---|---|---|---|
| SSR (default) | ssr: true | Dynamic pages, SEO, auth | Nitro cache per response |
| SSG / pre-rendering | nitro.prerender | Blogs, docs, static content | CDN cache (static files) |
| Client-only (SPA) | ssr: false | Admin UIs, dashboards, no SEO needed | Browser cache, no server |
| Hybrid rendering | routeRules per route | Marketing pages + app mixed | Configurable per route |
| ISR (incremental) | routeRules.isr: 60 | Product/blog pages needing updates | Stale-while-revalidate |
The hybrid rendering model is one of the strongest features of the Nuxt 3 architecture. With routeRules in nuxt.config.ts, different caching and rendering strategies can be configured per URL pattern: marketing pages are statically pre-rendered, product pages are updated with ISR, and the checkout route runs fully SSR without cache. This granularity makes it possible to apply performance optimizations exactly where they have the greatest effect.
Mironsoft
Nuxt 3 architecture, server routes, and full-stack Vue development
Need a Nuxt 3 project built on solid architecture?
We build Nuxt 3 applications with clear layer separation, Nitro caching, typed server routes, and embedded composables, scalable for teams and deployment requirements.
Server Route Design
Typed API layer with Nitro caching, validation, and auth middleware
Composable Architecture
useFetch wrappers, an auto-import strategy, and clean SSR hydration
Hybrid Rendering
routeRules configured for SSR, SSG, ISR, and client-only depending on page type
10. Summary
The Nuxt 3 architecture is a thoughtfully designed layered model: Nitro as a universal server with built-in caching and edge deployment capability, server routes as a secure API layer without a separate backend repository, composables with auto-import as a clear abstraction layer for reactive logic, and a flexible rendering model spanning SSR, SSG, and hybrid routing. Understanding these layers is the prerequisite for making Nuxt 3 decisions that do not become a burden as a project grows.
Common mistakes in Nuxt 3 projects stem from layer confusion: database code in Vue components, useFetch in event handlers, authentication implemented only in route middleware instead of also in server middleware, and .server.ts files that import browser APIs. With a clear picture of the three layers and their responsibilities, these mistakes can be systematically avoided, from the very first line of code.
The long-term architectural advantage of Nuxt 3 becomes especially visible as projects grow: new server routes can be added without touching existing client components. New composables are automatically imported without changing any build configuration. The rendering model can be adjusted per route as requirements change, without refactoring the entire application. This flexibility is the result of the clear layer separation that Nuxt 3 has pursued as a design principle from the start.
Nuxt 3 Architecture, the Essentials at a Glance
Nitro Engine
The universal server behind Nuxt 3. Built-in caching, edge deployment, H3-based. Compiles into a deployable bundle for every platform.
Server Routes
/server/api/ as a secure API layer. Database access, auth, and sensitive logic belong here, never in client code. Nitro caching with defineCachedEventHandler.
useFetch vs. $fetch
useFetch: SSR-aware, reactive, for page-render data. $fetch: programmatic, imperative, for button clicks and mutations. Not interchangeable.
Hybrid Rendering
routeRules in nuxt.config.ts: SSR, SSG, ISR, and SPA per route. Marketing pages static, checkout SSR, admin SPA, all in a single Nuxt project.