Nitro + H3: Build Universal TypeScript Server APIs That Deploy Anywhere

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

One codebase. Every runtime. Nitro is the open-source TypeScript server engine behind Nuxt, Analog, and Vinxi. It compiles your API routes into optimized bundles for Node.js, Cloudflare Workers, Vercel Edge, Deno, Bun, and more — with zero configuration changes. In this tutorial, you build a complete REST API and deploy it everywhere.

What You Will Learn

By the end of this tutorial, you will:

  • Set up a standalone Nitro project from scratch with TypeScript
  • Build API routes with typed request/response using H3
  • Create middleware for authentication, logging, and CORS
  • Integrate a database using Nitro's built-in storage layer
  • Implement WebSocket endpoints for real-time features
  • Add scheduled tasks (cron jobs) that run server-side
  • Use server plugins and lifecycle hooks
  • Deploy the same codebase to Node.js, Cloudflare Workers, Vercel, and Deno

Prerequisites

Before starting, ensure you have:

  • Node.js 20+ installed (node --version)
  • TypeScript experience (types, async/await)
  • Basic REST API concepts (HTTP methods, status codes)
  • A code editor — VS Code or Cursor recommended
  • Cloudflare account (optional, for Workers deployment)

Why Nitro?

Most TypeScript server frameworks lock you into a single runtime. Express runs on Node.js. Hono targets edge runtimes. Nitro is different — it compiles your server code into optimized bundles for any JavaScript runtime:

FeatureNitroExpressHonoFastify
Cross-runtime15+ presetsNode.js onlyPartialNode.js only
File-based routingBuilt-inManualManualManual
Auto-importsYesNoNoNo
Built-in storageKV, FS, Redis, D1ManualManualManual
WebSocketsBuilt-inws packageAdapterPlugin
Scheduled tasksBuilt-innode-cronNot built-inNot built-in
Tree-shakingAutomaticNoNoNo
Hot reloadBuilt-in dev servernodemonManualManual

Nitro is powered by H3, a minimal HTTP framework built for performance. H3 handles request parsing, routing, and response — while Nitro adds file-based routing, auto-imports, build optimization, and deployment presets.


Step 1: Create a New Nitro Project

Scaffold a fresh Nitro project:

npx giget@latest nitro nitro-api && cd nitro-api
npm install

Your project structure looks like this:

nitro-api/
├── routes/
│   └── index.ts        # GET /
├── nitro.config.ts      # Nitro configuration
├── tsconfig.json
└── package.json

Start the development server:

npx nitropack dev

Visit http://localhost:3000 — you should see the default response. The dev server has hot module replacement built in, so changes appear instantly.


Step 2: Build API Routes with H3

Nitro uses file-based routing. Every file in the routes/ directory becomes an API endpoint. The file path maps directly to the URL path.

Basic Routes

Create routes/api/health.ts:

export default defineEventHandler(() => {
  return { status: "ok", timestamp: Date.now() };
});

This responds to GET /api/health. Nitro auto-imports defineEventHandler — no import statement needed.

Route Parameters

Create routes/api/users/[id].ts:

export default defineEventHandler((event) => {
  const id = getRouterParam(event, "id");
 
  return {
    user: {
      id,
      name: `User ${id}`,
      email: `user${id}@example.com`,
    },
  };
});

Access it at GET /api/users/42. The [id] in the filename becomes a dynamic parameter.

HTTP Method Routing

Create method-specific handlers by naming files with the method suffix:

routes/
└── api/
    └── posts/
        ├── index.get.ts     # GET /api/posts
        ├── index.post.ts    # POST /api/posts
        └── [id].put.ts      # PUT /api/posts/:id
        └── [id].delete.ts   # DELETE /api/posts/:id

Create routes/api/posts/index.get.ts:

export default defineEventHandler(() => {
  return {
    posts: [
      { id: 1, title: "Getting Started with Nitro", published: true },
      { id: 2, title: "H3 Deep Dive", published: false },
    ],
  };
});

Create routes/api/posts/index.post.ts:

export default defineEventHandler(async (event) => {
  const body = await readBody(event);
 
  if (!body.title) {
    throw createError({
      statusCode: 400,
      statusMessage: "Title is required",
    });
  }
 
  return {
    post: {
      id: Math.floor(Math.random() * 1000),
      title: body.title,
      content: body.content || "",
      createdAt: new Date().toISOString(),
    },
  };
});

Typed Request Validation

For robust input validation, use H3's getValidatedBody and getValidatedQuery with a validation library:

npm install zod

Create routes/api/posts/index.post.ts with Zod validation:

import { z } from "zod";
 
const PostSchema = z.object({
  title: z.string().min(3).max(200),
  content: z.string().optional().default(""),
  tags: z.array(z.string()).optional().default([]),
  published: z.boolean().optional().default(false),
});
 
export default defineEventHandler(async (event) => {
  const body = await readValidatedBody(event, PostSchema.parse);
 
  return {
    post: {
      id: Math.floor(Math.random() * 1000),
      ...body,
      createdAt: new Date().toISOString(),
    },
  };
});

If validation fails, H3 automatically returns a 400 error with details.


Step 3: Middleware and Utilities

Request Middleware

Create middleware/ directory files that run before every route handler.

Create middleware/logger.ts:

export default defineEventHandler((event) => {
  const method = getMethod(event);
  const path = getRequestURL(event).pathname;
  console.log(`[${new Date().toISOString()}] ${method} ${path}`);
});

This logs every incoming request. Middleware handlers that do not return a value pass control to the next handler.

Route-Specific Middleware

Create middleware/api/auth.ts — this only runs for /api/* routes:

export default defineEventHandler((event) => {
  const authHeader = getHeader(event, "authorization");
 
  if (!authHeader?.startsWith("Bearer ")) {
    throw createError({
      statusCode: 401,
      statusMessage: "Missing or invalid authorization header",
    });
  }
 
  const token = authHeader.slice(7);
 
  // In production, verify JWT here
  event.context.userId = token;
});

Access the user ID in any API route via event.context.userId.

CORS Configuration

Nitro has built-in CORS support. Update nitro.config.ts:

export default defineNitroConfig({
  routeRules: {
    "/api/**": {
      cors: true,
      headers: {
        "Access-Control-Allow-Origin": "*",
        "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
        "Access-Control-Allow-Headers": "Content-Type, Authorization",
      },
    },
  },
});

Shared Utilities

Create reusable utilities in the utils/ directory. They are auto-imported throughout your project.

Create utils/response.ts:

export function successResponse<T>(data: T, message = "Success") {
  return {
    success: true,
    message,
    data,
  };
}
 
export function paginatedResponse<T>(
  data: T[],
  page: number,
  limit: number,
  total: number
) {
  return {
    success: true,
    data,
    pagination: {
      page,
      limit,
      total,
      totalPages: Math.ceil(total / limit),
    },
  };
}

Use in any route without importing:

export default defineEventHandler(() => {
  return successResponse({ version: "1.0.0" });
});

Step 4: Database Integration with Storage Layer

Nitro includes a universal storage layer powered by unstorage. It provides a key-value API that works with multiple backends — filesystem, Redis, Cloudflare KV, Vercel KV, and more.

Configure Storage

Update nitro.config.ts:

export default defineNitroConfig({
  storage: {
    posts: {
      driver: "fs",
      base: ".data/posts",
    },
    cache: {
      driver: "memory",
    },
  },
});

Use Storage in Routes

Create routes/api/posts/index.get.ts:

export default defineEventHandler(async () => {
  const keys = await useStorage("posts").getKeys();
 
  const posts = await Promise.all(
    keys.map(async (key) => {
      return await useStorage("posts").getItem(key);
    })
  );
 
  return successResponse(posts.filter(Boolean));
});

Create routes/api/posts/index.post.ts:

import { z } from "zod";
 
const PostSchema = z.object({
  title: z.string().min(3),
  content: z.string(),
  tags: z.array(z.string()).default([]),
});
 
export default defineEventHandler(async (event) => {
  const body = await readValidatedBody(event, PostSchema.parse);
  const id = `post-${Date.now()}`;
 
  const post = {
    id,
    ...body,
    createdAt: new Date().toISOString(),
    updatedAt: new Date().toISOString(),
  };
 
  await useStorage("posts").setItem(id, post);
 
  setResponseStatus(event, 201);
  return successResponse(post, "Post created");
});

Create routes/api/posts/[id].get.ts:

export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, "id");
  const post = await useStorage("posts").getItem(id!);
 
  if (!post) {
    throw createError({
      statusCode: 404,
      statusMessage: "Post not found",
    });
  }
 
  return successResponse(post);
});

Create routes/api/posts/[id].delete.ts:

export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, "id");
  const exists = await useStorage("posts").hasItem(id!);
 
  if (!exists) {
    throw createError({
      statusCode: 404,
      statusMessage: "Post not found",
    });
  }
 
  await useStorage("posts").removeItem(id!);
 
  return successResponse(null, "Post deleted");
});

Switch to Redis in Production

To use Redis instead of the filesystem, change the storage configuration:

export default defineNitroConfig({
  storage: {
    posts: {
      driver: "redis",
      url: process.env.REDIS_URL || "redis://localhost:6379",
    },
  },
});

No route code changes needed — the storage API is the same regardless of backend.


Step 5: Caching with Route Rules

Nitro has built-in caching. Cache API responses with zero code changes using route rules:

export default defineNitroConfig({
  routeRules: {
    "/api/posts": {
      cache: {
        maxAge: 60, // 60 seconds
      },
    },
    "/api/health": {
      cache: {
        maxAge: 10,
      },
    },
    "/api/posts/**": {
      cache: false, // No caching for individual posts
    },
  },
});

For programmatic caching, use cachedEventHandler:

export default cachedEventHandler(
  async () => {
    // This expensive operation is cached
    const allPosts = await fetchAllPosts();
    const stats = computeStats(allPosts);
    return successResponse(stats);
  },
  {
    maxAge: 300, // 5 minutes
    name: "post-stats",
  }
);

Step 6: WebSocket Support

Nitro supports WebSockets through the defineWebSocketHandler utility.

Create routes/ws.ts:

export default defineWebSocketHandler({
  open(peer) {
    console.log(`[ws] Client connected: ${peer.id}`);
    peer.send(JSON.stringify({ type: "welcome", id: peer.id }));
    peer.subscribe("chat");
  },
 
  message(peer, message) {
    const data = JSON.parse(message.text());
    console.log(`[ws] Message from ${peer.id}:`, data);
 
    // Broadcast to all subscribers
    peer.publish(
      "chat",
      JSON.stringify({
        type: "message",
        from: peer.id,
        text: data.text,
        timestamp: Date.now(),
      })
    );
  },
 
  close(peer) {
    console.log(`[ws] Client disconnected: ${peer.id}`);
  },
});

Enable WebSocket support in nitro.config.ts:

export default defineNitroConfig({
  experimental: {
    websocket: true,
  },
});

Connect from a client:

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({ text: "Hello from client!" }));
};

Step 7: Scheduled Tasks

Nitro supports cron-like scheduled tasks that run server-side.

Create tasks/cleanup.ts:

export default defineTask({
  meta: {
    name: "cleanup",
    description: "Remove expired data from storage",
  },
  async run() {
    const keys = await useStorage("posts").getKeys();
    let removed = 0;
 
    for (const key of keys) {
      const post: any = await useStorage("posts").getItem(key);
      if (!post) continue;
 
      const age = Date.now() - new Date(post.createdAt).getTime();
      const thirtyDays = 30 * 24 * 60 * 60 * 1000;
 
      if (age > thirtyDays && !post.published) {
        await useStorage("posts").removeItem(key);
        removed++;
      }
    }
 
    return { result: `Removed ${removed} expired drafts` };
  },
});

Configure the schedule in nitro.config.ts:

export default defineNitroConfig({
  experimental: {
    tasks: true,
  },
  scheduledTasks: {
    // Run cleanup every day at midnight
    "0 0 * * *": ["cleanup"],
  },
});

During development, trigger tasks manually via the built-in endpoint:

curl http://localhost:3000/_nitro/tasks/cleanup

Step 8: Server Plugins and Lifecycle

Create plugins that run when the server starts. Use them for database connections, service initialization, or logging.

Create plugins/startup.ts:

export default defineNitroPlugin((nitroApp) => {
  console.log("[plugin] Server starting...");
 
  // Hook into request lifecycle
  nitroApp.hooks.hook("request", (event) => {
    event.context.requestStartTime = Date.now();
  });
 
  nitroApp.hooks.hook("afterResponse", (event) => {
    const duration = Date.now() - (event.context.requestStartTime || 0);
    const path = getRequestURL(event).pathname;
    console.log(`[perf] ${path} - ${duration}ms`);
  });
 
  // Hook into server close
  nitroApp.hooks.hook("close", () => {
    console.log("[plugin] Server shutting down...");
  });
});

Step 9: Error Handling

Create a global error handler to standardize error responses.

Create plugins/error-handler.ts:

export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook("error", (error, { event }) => {
    console.error(`[error] ${error.message}`, {
      path: event ? getRequestURL(event).pathname : "unknown",
      stack: error.stack,
    });
  });
});

Create custom error responses in routes:

export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, "id");
 
  if (!id || !/^\d+$/.test(id)) {
    throw createError({
      statusCode: 400,
      statusMessage: "Invalid ID format",
      data: {
        field: "id",
        expected: "numeric string",
        received: id,
      },
    });
  }
 
  // Continue processing...
});

Step 10: Deploy Everywhere

This is where Nitro truly shines. The same codebase deploys to any platform by changing a single configuration value.

Deploy to Node.js (Default)

npx nitropack build
node .output/server/index.mjs

The build output is a self-contained server in .output/ — no node_modules needed in production.

Deploy to Cloudflare Workers

Update nitro.config.ts:

export default defineNitroConfig({
  preset: "cloudflare-pages",
});

Build and deploy:

npx nitropack build
npx wrangler pages deploy .output/public

Deploy to Vercel

export default defineNitroConfig({
  preset: "vercel-edge",
});

Push to Git — Vercel auto-detects and deploys.

Deploy to Deno Deploy

export default defineNitroConfig({
  preset: "deno-deploy",
});
npx nitropack build
deployctl deploy --project=my-api .output/server/index.ts

Deploy to Bun

export default defineNitroConfig({
  preset: "bun",
});
npx nitropack build
bun .output/server/index.mjs

All Available Presets

Nitro supports over 15 deployment targets:

PresetPlatform
nodeNode.js standalone server
bunBun runtime
deno-deployDeno Deploy
cloudflare-pagesCloudflare Pages
cloudflare-moduleCloudflare Workers
vercelVercel Serverless Functions
vercel-edgeVercel Edge Functions
netlifyNetlify Functions
netlify-edgeNetlify Edge Functions
aws-lambdaAWS Lambda
firebaseFirebase Functions
digital-oceanDigitalOcean App Platform
renderRender.com
dockerDocker container

Step 11: Production Configuration

Create a production-ready configuration:

export default defineNitroConfig({
  // Deployment target
  preset: process.env.NITRO_PRESET || "node",
 
  // Route-level caching
  routeRules: {
    "/api/**": {
      cors: true,
      headers: {
        "Access-Control-Allow-Origin": "*",
      },
    },
    "/api/posts": {
      cache: { maxAge: 60 },
    },
  },
 
  // Storage backends
  storage: {
    posts: {
      driver: process.env.REDIS_URL ? "redis" : "fs",
      ...(process.env.REDIS_URL
        ? { url: process.env.REDIS_URL }
        : { base: ".data/posts" }),
    },
  },
 
  // Runtime config (accessible via useRuntimeConfig())
  runtimeConfig: {
    apiSecret: process.env.API_SECRET || "dev-secret",
    public: {
      apiBase: process.env.API_BASE || "http://localhost:3000",
    },
  },
 
  // Enable experimental features
  experimental: {
    websocket: true,
    tasks: true,
  },
 
  // Scheduled tasks
  scheduledTasks: {
    "0 0 * * *": ["cleanup"],
  },
 
  // Compression
  compressPublicAssets: true,
});

Access runtime config in routes:

export default defineEventHandler(() => {
  const config = useRuntimeConfig();
  return {
    apiBase: config.public.apiBase,
    // Never expose secrets to responses
  };
});

Testing Your API

Test the complete API with curl:

# Health check
curl http://localhost:3000/api/health
 
# Create a post
curl -X POST http://localhost:3000/api/posts \
  -H "Content-Type: application/json" \
  -d '{"title": "My First Post", "content": "Hello Nitro!", "tags": ["intro"]}'
 
# List all posts
curl http://localhost:3000/api/posts
 
# Get a specific post (use the ID from the create response)
curl http://localhost:3000/api/posts/post-1712140800000
 
# Delete a post
curl -X DELETE http://localhost:3000/api/posts/post-1712140800000

Troubleshooting

"Cannot find module" errors

Nitro auto-imports utilities from H3 and its own runtime. If your editor shows errors, ensure tsconfig.json extends the generated types:

{
  "extends": "./.nitro/types/tsconfig.json"
}

Run npx nitropack prepare to generate types.

Storage not persisting

In development, the filesystem driver stores data in .data/. Make sure this directory exists and is writable. For production, use Redis or a cloud-native storage driver.

WebSocket connection refused

Ensure experimental.websocket is set to true in nitro.config.ts. Some deployment platforms (like Cloudflare Pages) require specific configuration for WebSocket support.

Build output too large

Nitro tree-shakes unused code automatically. If the output is still large, check for heavy dependencies imported at the top level. Use dynamic imports for optional features:

export default defineEventHandler(async () => {
  const { heavyLibrary } = await import("heavy-library");
  return heavyLibrary.process();
});

Next Steps

  • Add a database: Connect Drizzle ORM or Prisma for SQL database support
  • Add authentication: Implement JWT verification in middleware
  • Add rate limiting: Use Nitro's cache layer with unstorage for rate limiting
  • Build a frontend: Pair with any frontend framework — React, Vue, Svelte, or vanilla HTML
  • Explore Nuxt: If you need a full-stack framework, Nuxt uses Nitro as its server engine with the same APIs

Conclusion

Nitro and H3 give you a unique advantage in the TypeScript server ecosystem: write once, deploy anywhere. You built a complete REST API with typed validation, middleware, caching, WebSockets, scheduled tasks, and a universal storage layer. The same codebase runs on Node.js, Cloudflare Workers, Vercel Edge, Deno, and Bun without changing a single line of application code.

The key concepts you learned:

  • File-based routing maps filesystem paths to API endpoints
  • H3 utilities provide typed request/response handling
  • Storage layer abstracts away the database backend
  • Route rules configure caching and headers declaratively
  • Deployment presets compile your code for any runtime

Nitro is production-proven — it serves millions of requests daily as the engine behind Nuxt applications worldwide. Whether you are building a microservice, a REST API, or the backend for a full-stack application, Nitro ensures your server code is portable, performant, and future-proof.


Want to read more tutorials? Check out our latest tutorial on Fine-tuning Gemma for Function Calling.

Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles