Fix: Nitro Not Working — Server Routes Not Found, Middleware Not Running, or Deploy Failing
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:
- 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' }));
};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.
For related server issues, see Fix: Hono Not Working and Fix: Nuxt Not Working.
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.