Fix: Nitro Not Working — Server Routes Not Found, Middleware Not Running, or Deploy Failing
Part of: JavaScript & TypeScript Errors
Quick Answer
How to fix Nitro server engine issues — route handlers, middleware, storage with unstorage, caching, WebSocket support, deployment presets for Cloudflare Workers, Vercel, and Node.js.
The Problem
A Nitro server route returns 404 even though the file exists:
GET /api/users → 404 Not Found// server/api/users.ts
export default defineEventHandler(() => {
return { users: [] };
});
// File exists but route doesn't matchOr middleware runs but doesn’t modify the response:
// server/middleware/auth.ts
export default defineEventHandler((event) => {
console.log('Middleware hit');
// Request passes through but headers aren't set
});Or storage operations fail silently:
const data = await useStorage().getItem('cache:user:123');
// Always returns null even after setItemOr the build succeeds locally but the deployment crashes:
Error: [nitro] Cannot find module 'fs' (Cloudflare Workers)Why This Happens
Nitro is a server engine that powers Nuxt 3 and can also be used standalone. It uses file-system routing for API endpoints and middleware, and its key feature is the preset system that compiles the same source code into platform-specific output for ~20 deployment targets.
The file-system routing is where most “route not found” reports come from. Nitro discovers routes at build time by scanning server/api/, server/routes/, and server/middleware/. The path becomes the URL, the file extension determines the HTTP method, and the export must be a defineEventHandler call. Each of those three pieces fails differently — wrong path gives a 404, missing HTTP method suffix means GET-only routing, and a plain function export silently registers nothing.
The preset system is the second major source of bugs because the same source code runs in dramatically different environments. The Node preset gives you the full Node API surface. Cloudflare Workers, Vercel Edge, and Deno Deploy presets compile to V8 isolates with no Node APIs by default — fs, path, child_process, and net are unavailable, and any dependency that uses them must be replaced with a polyfill or a runtime-specific alternative. Nitro auto-applies unenv polyfills for the common cases, but obscure packages (anything that does process inspection or filesystem walking) still break. Compounding this, the Node preset itself behaves differently across Node 18 vs Node 22 — the newer --experimental-test-runner and built-in fetch are baseline on 22, while 18 needs flags or polyfills.
- File paths map to URL routes —
server/api/users.tsmaps to/api/users.server/routes/hello.tsmaps to/hello. The directory structure is the route definition. A misplaced file (e.g.,api/users.tswithout theserver/prefix) won’t register. - Middleware runs on every request but shouldn’t return a value — Nitro middleware files in
server/middleware/execute before route handlers. If middleware returns a value, it short-circuits the request and the route handler never runs. Return nothing (or explicitlyundefined) to pass through. - Storage is driver-based —
useStorage()defaults to in-memory storage, which doesn’t persist across restarts. For persistence, you need to configure a storage driver (filesystem, Redis, Cloudflare KV, etc.) innitro.config.ts. - Deployment presets determine available APIs — Cloudflare Workers and edge runtimes don’t have Node.js APIs (
fs,path,child_process). If your code or dependencies use these, the build succeeds but the deployment crashes. Thepresetin your Nitro config controls which APIs are available.
Fix 1: Define Route Handlers Correctly
# Standalone Nitro project
npx giget@latest nitro my-server
cd my-server && npm install// server/api/users.ts → GET /api/users
export default defineEventHandler(async (event) => {
return { users: [{ id: 1, name: 'Alice' }] };
// Automatically serialized to JSON with correct Content-Type
});
// server/api/users/[id].ts → GET /api/users/:id
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id');
return { id, name: 'Alice' };
});
// server/api/users.post.ts → POST /api/users
export default defineEventHandler(async (event) => {
const body = await readBody(event);
// body is already parsed (JSON, form data, etc.)
return { created: true, user: body };
});
// server/api/users/[id].put.ts → PUT /api/users/:id
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id');
const body = await readBody(event);
return { updated: true, id, ...body };
});
// server/api/users/[id].delete.ts → DELETE /api/users/:id
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id');
return { deleted: true, id };
});
// server/api/search.ts → GET /api/search?q=term&page=1
export default defineEventHandler(async (event) => {
const query = getQuery(event);
// query = { q: 'term', page: '1' }
return { results: [], query: query.q, page: Number(query.page) };
});
// Catch-all route: server/api/[...path].ts → /api/anything/here
export default defineEventHandler(async (event) => {
const path = getRouterParam(event, 'path');
return { matched: path };
});Route file naming rules:
| File Path | HTTP Method | URL |
|---|---|---|
server/api/users.ts | GET (default) | /api/users |
server/api/users.post.ts | POST | /api/users |
server/api/users.put.ts | PUT | /api/users |
server/api/users/[id].ts | GET | /api/users/:id |
server/routes/hello.ts | GET | /hello (no /api prefix) |
server/api/[...slug].ts | GET | /api/* (catch-all) |
Fix 2: Middleware That Actually Works
Middleware files run before every route handler:
// server/middleware/01.logger.ts — numbered prefix controls order
export default defineEventHandler((event) => {
console.log(`${event.method} ${getRequestURL(event).pathname}`);
// Return nothing — request continues to the next middleware / route handler
});
// server/middleware/02.auth.ts
export default defineEventHandler(async (event) => {
const url = getRequestURL(event);
// Skip auth for public routes
if (url.pathname.startsWith('/api/public')) return;
const token = getHeader(event, 'authorization')?.replace('Bearer ', '');
if (!token) {
// Throw an error to stop the request
throw createError({
statusCode: 401,
statusMessage: 'Unauthorized',
message: 'Missing authorization header',
});
}
try {
const user = await verifyToken(token);
// Attach user to the event context — available in route handlers
event.context.user = user;
} catch {
throw createError({ statusCode: 403, message: 'Invalid token' });
}
// Return nothing to continue to the route handler
});
// server/middleware/cors.ts — CORS headers
export default defineEventHandler((event) => {
setResponseHeaders(event, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
});
// Handle preflight
if (event.method === 'OPTIONS') {
setResponseStatus(event, 204);
return '';
}
});
// Access context in route handlers
// server/api/profile.ts
export default defineEventHandler((event) => {
const user = event.context.user; // Set by auth middleware
if (!user) throw createError({ statusCode: 401 });
return { name: user.name, email: user.email };
});Fix 3: Storage with unstorage
Nitro’s useStorage() provides a unified API for key-value storage:
// nitro.config.ts — configure storage drivers
export default defineNitroConfig({
storage: {
// In-memory (default — data lost on restart)
cache: { driver: 'memory' },
// File system
data: {
driver: 'fs',
base: './.data/storage',
},
// Redis
redis: {
driver: 'redis',
host: '127.0.0.1',
port: 6379,
db: 0,
},
// Cloudflare KV (when deploying to CF Workers)
kv: {
driver: 'cloudflare-kv-binding',
binding: 'MY_KV_NAMESPACE',
},
},
});// server/api/cache.ts — use storage in route handlers
export default defineEventHandler(async (event) => {
const storage = useStorage('data'); // Use the 'data' mount point
// Set a value
await storage.setItem('user:123', { name: 'Alice', role: 'admin' });
// Get a value
const user = await storage.getItem('user:123');
// Check existence
const exists = await storage.hasItem('user:123');
// List keys
const keys = await storage.getKeys('user:');
// ['user:123', 'user:456', ...]
// Remove
await storage.removeItem('user:123');
return { user, exists, keys };
});
// Cached route handler — Nitro's built-in caching
export default defineCachedEventHandler(
async (event) => {
// This handler's response is cached
const data = await fetchExpensiveData();
return data;
},
{
maxAge: 60 * 10, // Cache for 10 minutes
staleMaxAge: 60 * 60, // Serve stale for 1 hour while revalidating
swr: true, // Stale-while-revalidate
name: 'expensive-data', // Cache key name
getKey: (event) => {
// Custom cache key based on query params
const q = getQuery(event);
return `${q.page}-${q.sort}`;
},
}
);Fix 4: Error Handling
Structured error responses with proper HTTP status codes:
// server/api/users/[id].ts
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id');
// Validate input
if (!id || !/^\d+$/.test(id)) {
throw createError({
statusCode: 400,
statusMessage: 'Bad Request',
message: 'ID must be a number',
data: { field: 'id' }, // Additional data sent in response
});
}
const user = await findUser(id);
if (!user) {
throw createError({
statusCode: 404,
statusMessage: 'Not Found',
message: `User ${id} not found`,
});
}
return user;
});
// Global error handler
// server/middleware/error-handler.ts
export default defineEventHandler(async (event) => {
try {
// Let the request proceed
} catch (error) {
// This doesn't catch route handler errors —
// use Nitro's built-in error handling instead
}
});
// Nitro's built-in error handler — nitro.config.ts
export default defineNitroConfig({
errorHandler: '~/error-handler',
});
// error-handler.ts
import type { NitroErrorHandler } from 'nitropack';
const errorHandler: NitroErrorHandler = (error, event) => {
// Custom error response format
setResponseStatus(event, error.statusCode || 500);
setResponseHeader(event, 'Content-Type', 'application/json');
return send(event, JSON.stringify({
error: true,
message: error.message,
statusCode: error.statusCode || 500,
}));
};
export default errorHandler;Fix 5: Deploy to Different Platforms
Nitro’s preset system generates platform-specific output:
// nitro.config.ts
// Node.js server
export default defineNitroConfig({
preset: 'node-server',
// Output: .output/server/index.mjs — run with `node .output/server/index.mjs`
});
// Cloudflare Workers
export default defineNitroConfig({
preset: 'cloudflare-workers',
// Node.js APIs NOT available — no fs, path, child_process
// Use Cloudflare-specific bindings for storage, D1, R2
});
// Cloudflare Pages
export default defineNitroConfig({
preset: 'cloudflare-pages',
// API routes deploy as Cloudflare Pages Functions
});
// Vercel Serverless Functions
export default defineNitroConfig({
preset: 'vercel',
});
// Vercel Edge Functions
export default defineNitroConfig({
preset: 'vercel-edge',
});
// AWS Lambda
export default defineNitroConfig({
preset: 'aws-lambda',
});
// Deno Deploy
export default defineNitroConfig({
preset: 'deno-deploy',
});
// Bun
export default defineNitroConfig({
preset: 'bun',
});
// Static (pre-render all routes)
export default defineNitroConfig({
preset: 'static',
prerender: {
routes: ['/', '/about', '/api/health'],
crawlLinks: true, // Auto-discover linked pages
},
});Build and deploy:
# Build for the configured preset
npx nitro build
# Preview locally (mimics production)
npx nitro preview
# Or set preset via environment variable
NITRO_PRESET=cloudflare-pages npx nitro buildFix 6: WebSocket Support
Nitro supports WebSockets through h3’s built-in WebSocket API:
// server/routes/_ws.ts — WebSocket endpoint at /_ws
export default defineWebSocketHandler({
open(peer) {
console.log('Client connected:', peer.id);
peer.send(JSON.stringify({ type: 'welcome', id: peer.id }));
},
message(peer, message) {
const data = JSON.parse(message.text());
if (data.type === 'broadcast') {
// Send to all connected peers
peer.publish('chat', JSON.stringify({
from: peer.id,
message: data.message,
}));
}
},
close(peer, details) {
console.log('Client disconnected:', peer.id, details.reason);
},
error(peer, error) {
console.error('WebSocket error:', peer.id, error);
},
});// nitro.config.ts — enable WebSocket
export default defineNitroConfig({
experimental: {
websocket: true,
},
});// Client-side connection
const ws = new WebSocket('ws://localhost:3000/_ws');
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('Received:', data);
};
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'broadcast', message: 'Hello everyone' }));
};Fix 7: Preset Differences and Storage Driver Compatibility
Each Nitro preset has different constraints, and the storage driver you can use depends on it.
Node preset (Node 18 vs Node 22):
Node 18 is the minimum supported version, but Nitro projects often hit bugs caused by API differences between 18 and 22. Native fetch is fully stable on 20+ but still has rough edges on 18. The node:test runner, import.meta.dirname, and built-in WebSocket client are only baseline on 22. If your deploy target is Node 18 (some hosting platforms still default to it), pin a engines.node field in package.json and lint-check for 22-only APIs:
{
"engines": { "node": ">=18.18.0" }
}Vercel preset variants:
Nitro has three Vercel presets — vercel (serverless functions, Node runtime), vercel-edge (Edge Functions, V8 isolates), and vercel-static (static pre-render only). They have different API surfaces. vercel-edge cannot use Node APIs and cannot use storage drivers that need a filesystem; you must use cloudflare-kv-binding-style drivers or external HTTP-based KVs. Deployment failure modes differ too — see Fix: Vercel Deployment Failed for the typical errors when a build that works locally crashes on Vercel.
Cloudflare preset variants:
cloudflare-workers produces a single Worker bundle, while cloudflare-pages produces Pages Functions (one Worker per route group). The bundling target is the same V8 isolate runtime, but Pages adds asset handling and per-route deployments. Use Workers for pure API backends and Pages when you also have static assets. For Pages-specific debugging see Fix: Cloudflare Pages Not Working.
Edge runtime restrictions across providers:
| API | Node | Cloudflare | Vercel Edge | Deno Deploy | Bun |
|---|---|---|---|---|---|
fs, path | yes | no (unenv polyfill) | no | no | yes |
process.env | yes | env binding | env binding | Deno.env | yes |
setTimeout | yes | yes | yes | yes | yes |
| WebSocket server | yes | yes (DO) | no | yes | yes |
Native modules (.node) | yes | no | no | no | partial |
child_process | yes | no | no | no | yes |
The unenv polyfill auto-fills fs and path for Cloudflare with stub implementations that throw on actual use — code paths that touch the filesystem at runtime crash, but ones that just check typeof succeed.
Storage drivers per platform:
// nitro.config.ts — same code, different drivers per environment
export default defineNitroConfig({
storage: {
// Use env-aware mounts
cache: process.env.PRESET === 'cloudflare-workers'
? { driver: 'cloudflare-kv-binding', binding: 'CACHE' }
: process.env.REDIS_URL
? { driver: 'redis', url: process.env.REDIS_URL }
: { driver: 'fs', base: './.data/cache' },
},
});Common driver mappings:
- Node server with disk →
fs - Node server with Redis →
redis - Cloudflare Workers →
cloudflare-kv-binding,cloudflare-r2-binding, orcloudflare-d1-binding - Vercel KV / Upstash →
upstash - Vercel Blob → custom HTTP driver
- Deno Deploy → Deno KV via custom driver
Deno Deploy preset:
The Deno preset compiles to a Deno-compatible bundle and uses Deno.env for environment variables. Native dependencies fail; pure-JS dependencies work. WebSocket support uses Deno’s built-in handler.
Bun preset:
Bun’s preset wraps the Node-compatible API surface. Most Node code runs unchanged, but Bun’s faster startup means cold-path bugs (e.g., async initialization that’s slow on Node) surface earlier in Bun.
Hono vs Nitro vs Nuxt distinction:
People conflate these three. Nuxt is a meta-framework that ships Nitro internally. Standalone Nitro is the same server engine without the Vue/SSR layer. Hono is a different routing-focused framework that also targets edge runtimes. For Hono-specific patterns and how it overlaps with Nitro routes, see Fix: Hono Not Working. For Nuxt-specific configuration of the embedded Nitro instance, see Fix: Nuxt Not Working.
Still Not Working?
Route returns 404 but file exists — check the file is in the correct directory. server/api/ routes are served under /api/. server/routes/ routes are served at the root. If your Nitro project is inside a Nuxt app, the prefix is automatically /api/. Also verify the file exports a defineEventHandler — a file that exports a plain function won’t register as a route.
Middleware modifies event.context but route handler doesn’t see it — middleware must run before the handler and must not return a value. If middleware returns something (even an empty string), Nitro treats that as the response and skips the route handler. Check for accidental return statements.
useStorage() returns null after setItem — the default storage mount is memory, which is isolated per mount point. Make sure you’re using the same mount point: useStorage('data').setItem(...) and useStorage('data').getItem(...). Using useStorage() without an argument defaults to the root mount, which may not be the same as useStorage('data').
Build fails with “Cannot find module ‘X’” on edge/worker presets — the module uses Node.js APIs not available in the target runtime. Either find an edge-compatible alternative, or use Nitro’s routeRules to mark specific routes as running in a different runtime. For Cloudflare, use unenv polyfills: Nitro auto-polyfills common Node.js APIs, but some packages need explicit handling.
defineCachedEventHandler works in dev but returns stale data in production — the default memory cache resets on every cold start. In production on serverless, every cold start sees a cache miss. Configure a persistent storage mount (cloudflare-kv-binding, redis, upstash) and pass base: 'cache' (or a named mount) to the handler config so the cache survives across invocations.
Same code works on node-server but crashes on cloudflare-workers — most often a transitive dependency uses fs or crypto.createHash (legacy API). Run npx nitro build --preset=cloudflare-workers locally and check the build output for warnings. The build usually succeeds (because unenv stubs the missing APIs at type level) but the runtime call throws. Switch the dependency to a Web Crypto / fetch-based alternative.
Deploy succeeds but routes 500 on first request — happens when middleware throws during initialization. Nitro initializes middleware lazily on the first request; an error there cascades to a 500 for every endpoint. Wrap startup-sensitive logic in try/catch or move it to a deferred handler that only runs when needed.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Fastify Not Working — 404, Plugin Encapsulation, and Schema Validation Errors
How to fix Fastify issues — route 404 from plugin encapsulation, reply already sent, FST_ERR_VALIDATION, request.body undefined, @fastify/cors, hooks not running, and TypeScript type inference.
Fix: pdf-lib Not Working — PDF Not Generating, Fonts Not Embedding, or Pages Blank
How to fix pdf-lib issues — creating PDFs from scratch, modifying existing PDFs, embedding fonts and images, form filling, merging documents, and browser and Node.js usage.
Fix: Pusher Not Working — Events Not Received, Channel Auth Failing, or Connection Dropping
How to fix Pusher real-time issues — client and server setup, channel types, presence channels, authentication endpoints, event binding, connection management, and React integration.
Fix: Sharp Not Working — Installation Failing, Image Not Processing, or Build Errors on Deploy
How to fix Sharp image processing issues — native binary installation, resize and convert operations, Next.js image optimization, Docker setup, serverless deployment, and common platform errors.