Building API Routes with Nuxt 3's Nitro Server

Learn to build powerful API routes with Nuxt 3's Nitro server. Complete guide covering CRUD operations, authentication, file uploads, and production deployment.

Charles Allotey
Nuxt 3

The Mastering Nuxt FullStack Unleashed Course is here!

Get notified when we release new tutorials, lessons, and other expert Nuxt content.

Click here to view course

Building full-stack applications used to mean juggling multiple technologies, managing separate servers, and dealing with complex deployment pipelines. Nuxt 3's Nitro server changes this paradigm entirely, offering Vue.js and Nuxt developers a unified approach to creating both frontend and backend functionality within a single Nuxt codebase.

In this comprehensive guide, we'll explore how to harness the power of Nitro to build robust, scalable API routes that integrate seamlessly with your Nuxt applications. Whether you're building a simple contact form or a complex REST API, you'll discover how Nitro's innovative architecture makes server-side development both intuitive and powerful.

Understanding Nitro: The Engine Behind Modern Full-Stack Development

Nitro represents a fundamental shift in how we think about server-side development in the JavaScript ecosystem. Unlike traditional server frameworks that require extensive configuration, Nitro provides a zero-config approach that works out of the box while remaining highly customizable.

Key advantages that make Nitro special:

  • Universal Deployment: Deploy to any platform – serverless functions, edge networks, or traditional servers – without changing your code
  • Intelligent Auto-imports: Server utilities and composables are automatically available, reducing boilerplate
  • Hot Module Replacement: Changes to your server code reflect instantly during development
  • Built-in Performance: Automatic caching, compression, and optimization features
  • Type Safety: Full TypeScript support ensures reliable, maintainable code

The beauty of Nitro lies in its simplicity. Let's see how easy it is to get started.

Creating Your First API Route

Every API journey begins with a single route. In Nuxt 3, creating an API endpoint is as simple as adding a file to the server/api/ directory. Let's start with a basic example:

// server/api/hello.js
export default defineEventHandler((event) => {
  return {
    message: 'Welcome to Nitro API development',
    timestamp: new Date().toISOString(),
    environment: process.env.NODE_ENV
  }
})

This simple route demonstrates several important concepts:

  • File-based routing: The filename determines the endpoint URL (/api/hello)
  • Event handlers: The defineEventHandler function wraps your route logic
  • Automatic serialization: Return objects are automatically converted to JSON
  • Environment awareness: Access to Node.js environment variables

When you navigate to /api/hello, Nitro automatically handles the HTTP request, executes your function, and returns the JSON response with appropriate headers.

Dynamic Routes: Flexible URL Patterns

Real-world applications need to handle dynamic URLs. Nitro uses a file-naming convention with square brackets (eg. id.js) to create dynamic route segments:

// server/api/users/[id].js
export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, 'id')

  const user = await getUserById(id)

  if (!user) {
    throw createError({
      statusCode: 404,
      statusMessage: `User with ID ${id} not found`
    })
  }

  return {
    user,
    requestedId: id,
    retrievedAt: new Date().toISOString()
  }
})

This pattern is incredibly powerful because:

  • Flexible matching: /api/users/123, /api/users/john-doe, and /api/users/abc-xyz all work
  • Parameter extraction: getRouterParam safely extracts the dynamic segment
  • Type safety: TypeScript can infer parameter types for better development experience
  • Error handling: Nitro's createError function provides consistent error responses

HTTP Methods: Building Complete CRUD Operations

Modern APIs need to handle various HTTP methods. While you can create method-specific files (like users.post.js), it's often more maintainable to handle multiple methods in a single file:

// server/api/users/index.js
export default defineEventHandler(async (event) => {
  const method = getMethod(event)

  switch (method) {
    case 'GET':
      return await handleGetUsers(event)

    case 'POST':
      return await handleCreateUser(event)

    case 'PUT':
      return await handleUpdateUser(event)

    case 'DELETE':
      return await handleDeleteUser(event)

    default:
      throw createError({
        statusCode: 405,
        statusMessage: `Method ${method} not allowed`
      })
  }
})

async function handleGetUsers(event) {
  const users = await getAllUsers()
  return {
    users,
    total: users.length,
    retrievedAt: new Date().toISOString()
  }
}

async function handleCreateUser(event) {
  const userData = await readBody(event)
  const newUser = await createUser(userData)

  return {
    user: newUser,
    message: 'User created successfully'
  }
}

This approach provides several benefits:

  • Centralized logic: All user-related operations in one place
  • Consistent error handling: Shared error patterns across methods
  • Maintainability: Easier to manage related functionality
  • Resource organization: Clear separation of concerns

Working with Request Data: Input Handling Mastery

Effective API development requires robust handling of various input types. Nitro provides intuitive utilities for accessing request data:

Request Body Processing

// server/api/contact.post.js
export default defineEventHandler(async (event) => {
  const body = await readBody(event)

  // Validate required fields
  const requiredFields = ['name', 'email', 'message']
  const missingFields = requiredFields.filter(field => !body[field])

  if (missingFields.length > 0) {
    throw createError({
      statusCode: 400,
      statusMessage: `Missing required fields: ${missingFields.join(', ')}`
    })
  }

  // Process the contact submission
  const submission = {
    ...body,
    submittedAt: new Date().toISOString(),
    id: generateUniqueId()
  }

  await saveContactSubmission(submission)

  return {
    success: true,
    submissionId: submission.id,
    message: 'Contact form submitted successfully'
  }
})

Query Parameter Handling

Query parameters are essential for filtering, pagination, and search functionality:

// server/api/products/search.js
export default defineEventHandler(async (event) => {
  const query = getQuery(event)
  const {
    q,
    category = 'all',
    page = 1,
    limit = 20,
    sortBy = 'name',
    sortOrder = 'asc'
  } = query

  // Validate search query
  if (!q || q.trim().length < 2) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Search query must be at least 2 characters long'
    })
  }

  // Build search parameters
  const searchParams = {
    query: q.trim(),
    category,
    pagination: {
      page: Math.max(1, parseInt(page)),
      limit: Math.min(100, parseInt(limit)) // Prevent excessive results
    },
    sorting: {
      field: sortBy,
      order: sortOrder
    }
  }

  const results = await searchProducts(searchParams)

  return {
    query: q,
    results: results.items,
    pagination: {
      currentPage: searchParams.pagination.page,
      totalPages: Math.ceil(results.total / searchParams.pagination.limit),
      totalItems: results.total,
      itemsPerPage: searchParams.pagination.limit
    },
    appliedFilters: {
      category,
      sortBy,
      sortOrder
    }
  }
})

Headers and Cookies Management

Understanding how to work with headers and cookies is crucial for authentication, caching, and user experience:

// server/api/auth/profile.js
export default defineEventHandler(async (event) => {
  // Extract authentication information
  const authToken = getHeader(event, 'authorization')?.replace('Bearer ', '')
  const sessionId = getCookie(event, 'session-id')
  const preferredLanguage = getHeader(event, 'accept-language')

  // Validate authentication
  if (!authToken) {
    throw createError({
      statusCode: 401,
      statusMessage: 'Authentication token required'
    })
  }

  try {
    const user = await verifyAuthToken(authToken)

    // Update last activity
    const lastActivity = new Date().toISOString()
    setCookie(event, 'last-activity', lastActivity, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      maxAge: 60 * 60 * 24 * 30 // 30 days
    })

    // Set response headers
    setHeader(event, 'Cache-Control', 'private, no-cache')
    setHeader(event, 'Content-Language', user.preferredLanguage || 'en')

    return {
      user: {
        id: user.id,
        name: user.name,
        email: user.email,
        preferences: user.preferences
      },
      session: {
        id: sessionId,
        lastActivity,
        expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString()
      }
    }

  } catch (error) {
    throw createError({
      statusCode: 401,
      statusMessage: 'Invalid authentication token'
    })
  }
})

Middleware: The Guardian Layer

Middleware functions run before your API routes and provide a powerful way to implement cross-cutting concerns like authentication, logging, and request validation:

// server/middleware/auth.js
export default defineEventHandler(async (event) => {
  // Only process API routes
  if (!event.node.req.url?.startsWith('/api/')) {
    return
  }

  // Define public routes that don't require authentication
  const publicRoutes = [
    '/api/auth/login',
    '/api/auth/register',
    '/api/health',
    '/api/public/'
  ]

  const isPublicRoute = publicRoutes.some(route =>
    event.node.req.url?.startsWith(route)
  )

  if (isPublicRoute) {
    return
  }

  // Extract and validate authentication token
  const authHeader = getHeader(event, 'authorization')
  const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null

  if (!token) {
    throw createError({
      statusCode: 401,
      statusMessage: 'Authentication required'
    })
  }

  try {
    const user = await validateAuthToken(token)

    // Attach user information to the event context
    event.context.user = user
    event.context.authToken = token

    // Log authenticated request
    console.log(`Authenticated request: ${event.node.req.method} ${event.node.req.url} - User: ${user.id}`)

  } catch (error) {
    throw createError({
      statusCode: 401,
      statusMessage: 'Invalid or expired authentication token'
    })
  }
})

Database Integration: Data Persistence Made Simple

Modern applications require robust data persistence. Here's how to integrate popular database solutions with Nitro:

Using Prisma ORM

// server/api/posts/index.js
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

export default defineEventHandler(async (event) => {
  const method = getMethod(event)

  if (method === 'GET') {
    const query = getQuery(event)
    const { page = 1, limit = 10, author, tag } = query

    // Build dynamic query conditions
    const whereConditions = {}
    if (author) whereConditions.authorId = author
    if (tag) whereConditions.tags = { has: tag }

    const [posts, totalCount] = await Promise.all([
      prisma.post.findMany({
        where: whereConditions,
        include: {
          author: {
            select: {
              id: true,
              name: true,
              email: true
            }
          },
          _count: {
            select: {
              comments: true,
              likes: true
            }
          }
        },
        orderBy: {
          createdAt: 'desc'
        },
        skip: (page - 1) * limit,
        take: parseInt(limit)
      }),
      prisma.post.count({ where: whereConditions })
    ])

    return {
      posts,
      pagination: {
        currentPage: parseInt(page),
        totalPages: Math.ceil(totalCount / limit),
        totalItems: totalCount,
        itemsPerPage: parseInt(limit)
      },
      filters: {
        author,
        tag
      }
    }
  }

  if (method === 'POST') {
    const body = await readBody(event)
    const userId = event.context.user?.id

    if (!userId) {
      throw createError({
        statusCode: 401,
        statusMessage: 'Authentication required to create posts'
      })
    }

    const post = await prisma.post.create({
      data: {
        title: body.title,
        content: body.content,
        excerpt: body.excerpt || body.content.substring(0, 200),
        authorId: userId,
        tags: body.tags || [],
        published: body.published || false
      },
      include: {
        author: {
          select: {
            id: true,
            name: true,
            email: true
          }
        }
      }
    })

    return {
      post,
      message: 'Post created successfully'
    }
  }
})

Performance Optimization: Caching Strategies

Effective caching can dramatically improve your API's performance. Nitro provides built-in caching mechanisms that are both powerful and easy to use:

// server/api/analytics/dashboard.js
export default defineCacheEventHandler({
  // Cache for 5 minutes (in milliseconds)
  maxAge: 1000 * 60 * 5,
  // Use SWR (stale-while-revalidate) for better UX
  swr: true,
  // Vary cache by authorization header (per user)
  varies: ['authorization'],
  // Optionally, give the cache a custom name (per user)
  getKey: event => {
    const userId = event.context.user?.id
    return userId ? `dashboard-${userId}` : 'dashboard-anon'
  },
  async handler(event) {
    const userId = event.context.user?.id

    if (!userId) {
      throw createError({
        statusCode: 401,
        statusMessage: 'Authentication required'
      })
    }

    // Expensive operations
    const [userStats, recentActivity, performanceMetrics] = await Promise.all([
      getUserStatistics(userId),
      getRecentActivity(userId, 10),
      getPerformanceMetrics(userId)
    ])

    return {
      user: {
        id: userId,
        stats: userStats
      },
      recentActivity,
      performance: performanceMetrics,
      generatedAt: new Date().toISOString(),
      cached: true,
      cacheInfo: {
        strategy: 'stale-while-revalidate',
        ttl: 300 // 5 minutes
      }
    }
  }
})

File Upload Handling: Managing Binary Data

File uploads are a common requirement in modern applications. Here's how to handle them securely and efficiently:

// server/api/uploads/images.post.js
import { promises as fs } from 'fs'
import path from 'path'
import { createHash } from 'crypto'

const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB

export default defineEventHandler(async (event) => {
  const files = await readMultipartFormData(event)

  if (!files || files.length === 0) {
    throw createError({
      statusCode: 400,
      statusMessage: 'No files provided'
    })
  }

  const userId = event.context.user?.id
  if (!userId) {
    throw createError({
      statusCode: 401,
      statusMessage: 'Authentication required for file uploads'
    })
  }

  const uploadResults = []

  for (const file of files) {
    if (file.type !== 'file') continue

    // Validate file type
    if (!ALLOWED_TYPES.includes(file.type)) {
      throw createError({
        statusCode: 400,
        statusMessage: `File type ${file.type} not allowed. Supported types: ${ALLOWED_TYPES.join(', ')}`
      })
    }

    // Validate file size
    if (file.data.length > MAX_FILE_SIZE) {
      throw createError({
        statusCode: 400,
        statusMessage: `File size exceeds maximum limit of ${MAX_FILE_SIZE / 1024 / 1024}MB`
      })
    }

    // Generate secure filename
    const fileHash = createHash('md5').update(file.data).digest('hex')
    const extension = path.extname(file.filename || '')
    const filename = `${userId}-${Date.now()}-${fileHash}${extension}`

    // Ensure upload directory exists
    const uploadDir = path.join(process.cwd(), 'uploads', 'images')
    await fs.mkdir(uploadDir, { recursive: true })

    // Save file
    const filepath = path.join(uploadDir, filename)
    await fs.writeFile(filepath, file.data)

    // Store file metadata
    const fileMetadata = {
      id: generateUniqueId(),
      filename,
      originalName: file.filename,
      size: file.data.length,
      type: file.type,
      uploadedBy: userId,
      uploadedAt: new Date().toISOString(),
      hash: fileHash
    }

    await saveFileMetadata(fileMetadata)

    uploadResults.push({
      id: fileMetadata.id,
      filename,
      originalName: file.filename,
      size: file.data.length,
      url: `/uploads/images/${filename}`
    })
  }

  return {
    success: true,
    files: uploadResults,
    message: `Successfully uploaded ${uploadResults.length} file(s)`
  }
})

Error Handling: Building Resilient APIs

Robust error handling is essential for production applications. Here's how to implement comprehensive error management:

// server/api/users/[id].put.js
import Joi from 'joi'

// Define validation schema
const updateUserSchema = Joi.object({
  name: Joi.string().min(2).max(100).trim(),
  email: Joi.string().email().lowercase(),
  age: Joi.number().integer().min(13).max(150),
  preferences: Joi.object({
    theme: Joi.string().valid('light', 'dark', 'auto'),
    notifications: Joi.boolean(),
    language: Joi.string().length(2)
  })
})

export default defineEventHandler(async (event) => {
  const startTime = Date.now()

  try {
    const id = getRouterParam(event, 'id')
    const body = await readBody(event)
    const currentUser = event.context.user

    // Authorization check
    if (currentUser.id !== id && !currentUser.isAdmin) {
      throw createError({
        statusCode: 403,
        statusMessage: 'Insufficient permissions to update this user'
      })
    }

    // Validate input data
    const { error, value } = updateUserSchema.validate(body, {
      abortEarly: false,
      stripUnknown: true
    })

    if (error) {
      throw createError({
        statusCode: 400,
        statusMessage: 'Validation failed',
        data: {
          errors: error.details.map(detail => ({
            field: detail.path.join('.'),
            message: detail.message
          }))
        }
      })
    }

    // Check if user exists
    const existingUser = await getUserById(id)
    if (!existingUser) {
      throw createError({
        statusCode: 404,
        statusMessage: `User with ID ${id} not found`
      })
    }

    // Check for email conflicts
    if (value.email && value.email !== existingUser.email) {
      const emailExists = await checkEmailExists(value.email)
      if (emailExists) {
        throw createError({
          statusCode: 409,
          statusMessage: 'Email address already in use'
        })
      }
    }

    // Update user
    const updatedUser = await updateUser(id, value)

    // Log the update
    await logUserActivity({
      userId: id,
      action: 'profile_updated',
      updatedBy: currentUser.id,
      changes: Object.keys(value),
      timestamp: new Date().toISOString()
    })

    return {
      user: {
        id: updatedUser.id,
        name: updatedUser.name,
        email: updatedUser.email,
        age: updatedUser.age,
        preferences: updatedUser.preferences,
        updatedAt: updatedUser.updatedAt
      },
      message: 'User updated successfully',
      processingTime: Date.now() - startTime
    }

  } catch (error) {
    // Log error for monitoring
    console.error('User update error:', {
      userId: getRouterParam(event, 'id'),
      error: error.message,
      stack: error.stack,
      processingTime: Date.now() - startTime
    })

    // Re-throw known errors
    if (error.statusCode) {
      throw error
    }

    // Handle unexpected errors
    throw createError({
      statusCode: 500,
      statusMessage: 'Internal server error occurred while updating user'
    })
  }
})

Production Deployment: Going Live

When deploying your Nitro API to production, consider these essential configurations:

// server/api/health.js
export default defineEventHandler(() => {
  const config = useRuntimeConfig()
  const uptime = process.uptime()

  return {
    status: 'healthy',
    timestamp: new Date().toISOString(),
    uptime: Math.floor(uptime),
    environment: config.public.environment,
    version: config.public.appVersion || '1.0.0',
    database: {
      connected: true, // Add actual database health check
      latency: '< 50ms'
    },
    memory: {
      used: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
      total: Math.round(process.memoryUsage().heapTotal / 1024 / 1024)
    }
  }
})

Best Practices for Production APIs

Security Considerations

  • Input Validation: Always validate and sanitize user input
  • Authentication: Implement robust authentication mechanisms
  • Rate Limiting: Prevent abuse with appropriate rate limiting
  • HTTPS: Use HTTPS in production environments
  • CORS: Configure CORS headers appropriately
  • Environment Variables: Keep sensitive data in environment variables

Conclusion

Nuxt 3's Nitro server represents a paradigm shift in full-stack development, offering developers a powerful, flexible, and intuitive way to build APIs. By following the patterns and practices outlined in this guide, you'll be well-equipped to create production-ready APIs that are both maintainable and performant.

The key to mastering Nitro lies in understanding its conventions, leveraging its built-in features, and following established best practices. Whether you're building a simple API for a personal project or a complex system for enterprise use, Nitro provides the foundation you need to succeed.

Remember that great APIs are built iteratively. Start with the basics, add features as needed, and always prioritize security, performance, and maintainability. With Nitro's powerful features at your disposal, you have everything you need to build exceptional server-side applications.

Charles Allotey
Charles is a Frontend Developer at Vueschool. Has a passion for building great experiences and products using Vue.js and Nuxt.

Follow MasteringNuxt on