Skip to content

Fix: Nitro Not Working — Server Routes Not Found, Middleware Not Running, or Deploy Failing

FixDevs · (Updated: )

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 match

Or 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 setItem

Or 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 routesserver/api/users.ts maps to /api/users. server/routes/hello.ts maps to /hello. The directory structure is the route definition. A misplaced file (e.g., api/users.ts without the server/ 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 explicitly undefined) to pass through.
  • Storage is driver-baseduseStorage() 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.) in nitro.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. The preset in 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 PathHTTP MethodURL
server/api/users.tsGET (default)/api/users
server/api/users.post.tsPOST/api/users
server/api/users.put.tsPUT/api/users
server/api/users/[id].tsGET/api/users/:id
server/routes/hello.tsGET/hello (no /api prefix)
server/api/[...slug].tsGET/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 build

Fix 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:

APINodeCloudflareVercel EdgeDeno DeployBun
fs, pathyesno (unenv polyfill)nonoyes
process.envyesenv bindingenv bindingDeno.envyes
setTimeoutyesyesyesyesyes
WebSocket serveryesyes (DO)noyesyes
Native modules (.node)yesnononopartial
child_processyesnononoyes

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, or cloudflare-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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles