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

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:
| Feature | Nitro | Express | Hono | Fastify |
|---|---|---|---|---|
| Cross-runtime | 15+ presets | Node.js only | Partial | Node.js only |
| File-based routing | Built-in | Manual | Manual | Manual |
| Auto-imports | Yes | No | No | No |
| Built-in storage | KV, FS, Redis, D1 | Manual | Manual | Manual |
| WebSockets | Built-in | ws package | Adapter | Plugin |
| Scheduled tasks | Built-in | node-cron | Not built-in | Not built-in |
| Tree-shaking | Automatic | No | No | No |
| Hot reload | Built-in dev server | nodemon | Manual | Manual |
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 installYour 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 devVisit 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 zodCreate 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/cleanupStep 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.mjsThe 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/publicDeploy 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.tsDeploy to Bun
export default defineNitroConfig({
preset: "bun",
});npx nitropack build
bun .output/server/index.mjsAll Available Presets
Nitro supports over 15 deployment targets:
| Preset | Platform |
|---|---|
node | Node.js standalone server |
bun | Bun runtime |
deno-deploy | Deno Deploy |
cloudflare-pages | Cloudflare Pages |
cloudflare-module | Cloudflare Workers |
vercel | Vercel Serverless Functions |
vercel-edge | Vercel Edge Functions |
netlify | Netlify Functions |
netlify-edge | Netlify Edge Functions |
aws-lambda | AWS Lambda |
firebase | Firebase Functions |
digital-ocean | DigitalOcean App Platform |
render | Render.com |
docker | Docker 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-1712140800000Troubleshooting
"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
unstoragefor 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.
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

Build and Deploy a Serverless API with Cloudflare Workers, Hono, and D1
Learn how to build a production-ready serverless REST API using Cloudflare Workers, the Hono web framework, and D1 SQLite database — from project setup to global deployment.

Build a Type-Safe GraphQL API with Next.js App Router, Yoga, and Pothos
Learn how to build a fully type-safe GraphQL API using Next.js 15 App Router, GraphQL Yoga, and Pothos schema builder. This hands-on tutorial covers schema design, queries, mutations, authentication middleware, and a React client with urql.

Mistral AI API with TypeScript: Building Intelligent Applications
Learn how to use the Mistral AI API with TypeScript to build intelligent applications: chat, structured generation, function calling, and RAG.