Skip to content

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

FixDevs ·

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:

  • 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' }));
};

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.

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