Server-Side API Routes
Bob needs to add backend APIs to MegaShop—including CRUD operations for products, shopping cart functionality, and user authentication. The traditional approach would require deploying a separate Express server, which makes frontend-backend integration a headache. Charlie discovered that Nuxt 3 allows you to write APIs directly in the server/ directory, handling both frontend and backend within a single project.
1. What You'll Learn
- server/api/ Route Definitions and HTTP Method Mappings
- Event handling: defineEventHandler / readBody / getQuery / getRouterParam
- server/middleware/ Global Interception
- Cross-Domain and CORS Handling
- Hands-On Guide to MegaShop /api/products / /api/cart / /api/auth APIs
2. A True Story of an Administrator
(1) Pain Point: The Nightmare of Integrating Separate Front-End and Back-End Systems
Bob wrote a standalone API server using Express, running on port 4000. The Nuxt frontend runs on port 3000. During development, he had to configure a proxy and handle cross-origin requests, and deployment required two separate CI/CD pipelines. Alice's shopping cart requests were occasionally blocked due to cross-origin issues, resulting in errors in production.
(2) Solution for the Nuxt Server API
In Nuxt 3, the server/ directory allows you to write APIs directly within the project; since they are on the same domain and port, there are no cross-domain issues:
// server/api/products/index.get.ts
export default defineEventHandler(() => {
return { items: products, total: 1000000 }
})
(3) Benefits: All-in-One Full-Stack Solution
Bob maintains only one project, with the API and front end deployed on the same domain, so Alice will never encounter cross-domain errors again, and the deployment process is simplified by 50%.
3. API Route Definitions
(1) File Routing Mapping Rules
| File Path | HTTP Method | Route URL |
|---|---|---|
| server/api/products.ts | ALL | /api/products |
| server/api/products/index.ts | ALL | /api/products |
| server/api/products/index.get.ts | GET | /api/products |
| server/api/products/index.post.ts | POST | /api/products |
| server/api/products/[id].get.ts | GET | /api/products/:id |
| server/api/products/[id].put.ts | PUT | /api/products/:id |
| server/api/products/[id].delete.ts | DELETE | /api/products/:id |
| server/api/auth/login.post.ts | POST | /api/auth/login |
(2) API Request Processing Lifecycle
sequenceDiagram
participant C as Client
participant M as Server Middleware
participant H as Event Handler
participant D as Data Source
C->>M: HTTP Request
M->>M: Auth check / CORS / Logging
M->>H: Pass event
H->>D: Query / Mutation
D-->>H: Data result
H-->>M: HTTP Response
M-->>C: JSON Response
(1) ▶ Example: GET Product List
// server/api/products/index.get.ts
export default defineEventHandler((event) => {
const query = getQuery(event)
const page = Number(query.page) || 1
const limit = Number(query.limit) || 20
const category = query.category as string
let filtered = mockProducts
if (category) {
filtered = filtered.filter(p => p.category === category)
}
const start = (page - 1) * limit
return {
items: filtered.slice(start, start + limit),
total: filtered.length,
page,
limit
}
})
Output:
// Execution Successful
(2) ▶ Example: POST to Create a Product
// server/api/products/index.post.ts
export default defineEventHandler(async (event) => {
const body = await readBody(event)
// Validate required fields
if (!body.name || !body.price) {
throw createError({
statusCode: 400,
message: 'Name and price are required'
})
}
const newProduct = {
id: mockProducts.length + 1,
name: body.name,
price: Number(body.price),
category: body.category || 'uncategorized',
inStock: body.inStock ?? true,
image: body.image || '/images/placeholder.webp'
}
mockProducts.push(newProduct)
return { product: newProduct, message: 'Product created successfully' }
})
Output:
// Execution Successful
(3) ▶ Example: Dynamic Parameter Routing
// server/api/products/[id].get.ts
export default defineEventHandler((event) => {
const id = Number(getRouterParam(event, 'id'))
const product = mockProducts.find(p => p.id === id)
if (!product) {
throw createError({
statusCode: 404,
message: 'Product not found'
})
}
return product
})
Output:
// Execution Successful
(4) ▶ Example: PUT to Update a Product
// server/api/products/[id].put.ts
export default defineEventHandler(async (event) => {
const id = Number(getRouterParam(event, 'id'))
const body = await readBody(event)
const index = mockProducts.findIndex(p => p.id === id)
if (index === -1) {
throw createError({ statusCode: 404, message: 'Product not found' })
}
mockProducts[index] = { ...mockProducts[index], ...body }
return { product: mockProducts[index], message: 'Product updated' }
})
Output:
// Execution Successful
(5) ▶ Example: DELETE—Delete a product
// server/api/products/[id].delete.ts
export default defineEventHandler((event) => {
const id = Number(getRouterParam(event, 'id'))
const index = mockProducts.findIndex(p => p.id === id)
if (index === -1) {
throw createError({ statusCode: 404, message: 'Product not found' })
}
const deleted = mockProducts.splice(index, 1)
return { product: deleted[0], message: 'Product deleted' }
})
Output:
// Execution Successful
4. Event-handling utility functions
(1) Quick Reference for Request Tools
| Function | Purpose | Example |
|---|---|---|
| getQuery(event) | Retrieve URL query parameters | { page: '1', limit: '20' } |
| getRouterParam(event, key) | Get route parameter | /products/123 → '123' |
| readBody(event) | Read request body | { name: 'Product', price: 99 } |
| getHeader(event, key) | Get request header | 'Bearer token...' |
| getCookie(event, key) | Get Cookie | 'session-id-xxx' |
| setCookie(event, key, val, opts) | Set a cookie | setCookie(event, 'token', jwt, { httpOnly: true }) |
| setHeader(event, key, val) | Set response header | setHeader(event, 'x-total', '1000') |
| createError(opts) | Throw an error | createError({ statusCode: 404 }) |
(2) Response Format Conventions
| Scenario | Status Code | Response Body |
|---|---|---|
| Query successful | 200 | { items: [], total: 0 } |
| Created successfully | 201 | { product: {}, message: '...' } |
| Update successful | 200 | { product: {}, message: '...' } |
| Deleted successfully | 200 | { message: '...' } |
| Parameter error | 400 | { statusCode: 400, message: '...' } |
| Unauthenticated | 401 | { statusCode: 401, message: '...' } |
| Not Found | 404 | { statusCode: 404, message: '...' } |
5. Server Middleware
(1) ▶ Example: Global Log Middleware
// server/middleware/logger.ts
export default defineEventHandler((event) => {
const start = Date.now()
const method = getMethod(event)
const url = getRequestURL(event)
// Log after response
event.node.res.on('finish', () => {
const duration = Date.now() - start
const status = event.node.res.statusCode
console.log(`${method} ${url} → ${status} (${duration}ms)`)
})
})
Output:
// Execution Successful
(2) ▶ Example: Auth middleware
// server/middleware/auth.ts
export default defineEventHandler((event) => {
const protectedPaths = ['/api/admin', '/api/orders', '/api/cart']
const url = getRequestURL(event)
const isProtected = protectedPaths.some(path => url.pathname.startsWith(path))
if (!isProtected) return
const token = getHeader(event, 'authorization')?.replace('Bearer ', '')
if (!token) {
throw createError({ statusCode: 401, message: 'Authentication required' })
}
// Verify JWT token (simplified)
try {
const payload = verifyToken(token)
event.context.user = payload
} catch {
throw createError({ statusCode: 401, message: 'Invalid token' })
}
})
Output:
// Execution Successful
6. CORS Cross-Origin Handling
(1) ▶ Example: Nitro's Built-in CORS Configuration
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
'/api/**': { cors: true }
}
})
Output:
// Execution Successful
(2) ▶ Example: Custom CORS Middleware
// server/middleware/cors.ts
export default defineEventHandler((event) => {
const origin = getHeader(event, 'origin') || '*'
setHeader(event, 'Access-Control-Allow-Origin', origin)
setHeader(event, 'Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
setHeader(event, 'Access-Control-Allow-Headers', 'Content-Type, Authorization')
setHeader(event, 'Access-Control-Max-Age', '86400')
// Handle preflight
if (getMethod(event) === 'OPTIONS') {
event.node.res.statusCode = 204
return ''
}
})
Output:
// Execution Successful
(1) Comparison of CORS Solutions
| Solution | Configuration Method | Flexibility | Use Cases |
|---|---|---|---|
| routeRules cors: true | nuxt.config.ts | Low (All On/All Off) | Development Environment |
| Custom middleware | server/middleware/ | High (fine-grained) | Production |
| Nitro routeRules Advanced | routeRules per-route | Medium | Mixed Requirements |
7. Comprehensive Example: The MegaShop API Framework
// server/api/cart/index.get.ts - Get cart items
export default defineEventHandler((event) => {
const userId = event.context.user?.id
if (!userId) {
throw createError({ statusCode: 401, message: 'Login required' })
}
const cart = mockCarts.find(c => c.userId === userId)
return cart || { userId, items: [], total: 0 }
})
// server/api/cart/index.post.ts - Add item to cart
export default defineEventHandler(async (event) => {
const userId = event.context.user?.id
const { productId, quantity = 1 } = await readBody(event)
const product = mockProducts.find(p => p.id === productId)
if (!product) {
throw createError({ statusCode: 404, message: 'Product not found' })
}
if (!product.inStock) {
throw createError({ statusCode: 400, message: 'Product out of stock' })
}
// Add or update cart item
let cart = mockCarts.find(c => c.userId === userId)
if (!cart) {
cart = { userId, items: [], total: 0 }
mockCarts.push(cart)
}
const existing = cart.items.find(i => i.productId === productId)
if (existing) {
existing.quantity += quantity
} else {
cart.items.push({ productId, name: product.name, price: product.price, quantity })
}
cart.total = cart.items.reduce((sum, i) => sum + i.price * i.quantity, 0)
return { cart, message: 'Item added to cart' }
})
❓ FAQ
server/ directory included in the client bundle?server/ code separately, so it does not leak to the client. The server code can safely use Node.js APIs and secrets.createError and throw new Error?createError is provided by H3 and returns the correct HTTP status code and a JSON response. throw new Error returns a 500 internal error. We recommend using createError in API routes.server/middleware/ and intercepts API requests (authorization, logging, CORS). Client middleware is located in the middleware/ directory and intercepts page route transitions (permission guards/redirects).runtimeConfig in the API?useRuntimeConfig() to retrieve them; private variables are only available on the server: const config = useRuntimeConfig() → config.databaseUrl.event.node.res.write() and set Content-Type: text/event-stream to implement SSE. Nitro fully supports the native Node.js response API.📖 Summary
- The
server/directory in Nuxt 3 is the API: file paths map to routes, and method suffixes map to HTTP methods - defineEventHandler + readBody/getQuery/getRouterParam to process the request
- Server middleware global interceptors: logging, authentication, CORS
- Deploying within the same domain naturally avoids cross-domain issues; if necessary, you can use
routeRules corsor custom middleware. - MegaShop builds a complete backend using /api/products, /api/cart, and /api/auth
📝 Exercises
- Basic Exercise (Difficulty: ⭐): Create the GET /api/products and GET /api/products/[id] endpoints, which return mock product data.
- Advanced Exercise (Difficulty: ⭐⭐): Implement a complete CRUD API (GET/POST/PUT/DELETE), including parameter validation and error handling.
- Challenge (Difficulty: ⭐⭐⭐): Implement authentication using server-side middleware. Return a 401 error when an unauthenticated user accesses
/api/cart; allow normal operations after login.
---|



