Serverless functions with Next.js best practices

Frontend Lead
March 12, 2025
0 MIN READ
#cicd#react#serverless#functions

Serverless Functions with Next.js: Best Practices for Optimal Performance

Introduction

Serverless functions have revolutionized how developers build and deploy backend logic, and Next.js has emerged as one of the most popular frameworks for implementing them. With its built-in API routes feature, Next.js makes it incredibly easy to create serverless functions that scale automatically while maintaining excellent developer experience.

In this guide, we'll explore best practices for working with serverless functions in Next.js, covering everything from performance optimization to security considerations. Whether you're building API endpoints, webhook handlers, or background tasks, these patterns will help you create robust, efficient serverless functions.

Structuring Your Serverless Functions

Proper organization of your serverless functions is crucial for maintainability as your application grows. Next.js provides two primary approaches: API routes and route handlers (introduced in Next.js 13+).

API Routes (Pages Router)

In the Pages Router (pre-Next.js 13), you create API routes by adding files to the pages/api directory. Each file becomes a serverless function:

// pages/api/users.js export default function handler(req, res) { if (req.method === 'GET') { // Handle GET request res.status(200).json({ users: [] }); } else if (req.method === 'POST') { // Handle POST request res.status(201).json({ success: true }); } else { res.setHeader('Allow', ['GET', 'POST']); res.status(405).end(`Method ${req.method} Not Allowed`); } }

Route Handlers (App Router)

With the App Router (Next.js 13+), route handlers are placed in the app directory:

// app/api/users/route.js export async function GET() { return Response.json({ users: [] }); } export async function POST() { return Response.json({ success: true }, { status: 201 }); }

Best practices for structure:

  1. Group related endpoints by resource (e.g., /api/users, /api/products)
  2. Use HTTP methods appropriately (GET for reads, POST for creates, etc.)
  3. Keep individual functions focused on a single responsibility
  4. Consider separating business logic from route handlers for better testability

Performance Optimization

Serverless functions have cold starts and execution time limits, so performance optimization is critical.

Reduce Bundle Size

Smaller functions start faster. Use these techniques:

  1. Tree-shaking: Only import what you need
  2. Externalize dependencies: Move large, rarely changing dependencies to layers (if supported by your deployment platform)
  3. Minimize dependencies: Avoid heavy libraries when possible
// Good: Only import what you need
import { getFirestore } from 'firebase-admin/firestore';

// Bad: Importing the entire package
import * as admin from 'firebase-admin';

Implement Caching

Cache responses when appropriate:

// pages/api/products.js import { unstable_cache } from 'next/cache'; const getProducts = unstable_cache( async () => { const res = await fetch('https://external-api.com/products'); return res.json(); }, ['products'], { revalidate: 3600 } // 1 hour ); export default async function handler(req, res) { const products = await getProducts(); res.status(200).json(products); }

Connection Pooling

For database connections, use connection pooling to avoid creating new connections on every invocation:

// lib/db.js import { Pool } from 'pg'; const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 5, // Maximum number of connections in pool }); export { pool }; // pages/api/users.js import { pool } from '../../lib/db'; export default async function handler(req, res) { const client = await pool.connect(); try { const result = await client.query('SELECT * FROM users'); res.status(200).json(result.rows); } finally { client.release(); } }

Security Best Practices

Serverless functions are publicly accessible by default, so security must be a priority.

Authentication and Authorization

Always validate requests:

// pages/api/protected.js import { getSession } from 'next-auth/react'; export default async function handler(req, res) { const session = await getSession({ req }); if (!session) { return res.status(401).json({ error: 'Unauthorized' }); } // Handle authenticated request res.status(200).json({ secretData: '...' }); }

Input Validation

Never trust client input:

// pages/api/users.js import * as yup from 'yup'; const userSchema = yup.object().shape({ name: yup.string().required(), email: yup.string().email().required(), }); export default async function handler(req, res) { try { const validatedData = await userSchema.validate(req.body); // Process valid data res.status(200).json({ success: true }); } catch (error) { res.status(400).json({ error: error.message }); } }

Environment Variables

Use environment variables for sensitive configuration:

// next.config.js module.exports = { env: { API_SECRET: process.env.API_SECRET, }, }; // pages/api/secure.js export default function handler(req, res) { if (req.headers.authorization !== process.env.API_SECRET) { return res.status(403).json({ error: 'Forbidden' }); } // Handle secure request }

Error Handling and Logging

Robust error handling improves reliability and debuggability.

Structured Error Handling

// lib/errors.js export class APIError extends Error { constructor(message, statusCode = 500) { super(message); this.statusCode = statusCode; } } // pages/api/products/[id].js import { APIError } from '../../lib/errors'; export default async function handler(req, res) { try { if (!req.query.id) { throw new APIError('Product ID required', 400); } const product = await getProductById(req.query.id); if (!product) { throw new APIError('Product not found', 404); } res.status(200).json(product); } catch (error) { const status = error.statusCode || 500; const message = error.message || 'Internal Server Error'; res.status(status).json({ error: message }); } }

Logging

Implement comprehensive logging:

// lib/logger.js import pino from 'pino'; const logger = pino({ level: process.env.LOG_LEVEL || 'info', formatters: { level: (label) => ({ level: label }), }, }); export default logger; // pages/api/orders.js import logger from '../../lib/logger'; export default async function handler(req, res) { logger.info({ method: req.method }, 'Request received'); try { // Process order logger.info('Order processed successfully'); res.status(200).json({ success: true }); } catch (error) { logger.error({ error }, 'Order processing failed'); res.status(500).json({ error: 'Processing failed' }); } }

Conclusion

Serverless functions in Next.js offer a powerful way to build scalable backend functionality without managing infrastructure. By following these best practices—proper structuring, performance optimization, security hardening, and robust error handling—you can create serverless functions that are maintainable, efficient, and secure.

Remember that serverless functions are not a silver bullet. Evaluate whether a serverless approach makes sense for your specific use case, considering factors like cold starts, execution time limits, and cost. For many modern web applications, especially those built with Next.js, serverless functions provide an excellent balance between developer productivity and operational efficiency.

As you implement these patterns, monitor your functions' performance and adjust your approach based on real-world usage. The Next.js team continues to improve serverless capabilities, so stay updated with the latest features and recommendations from the framework's documentation.

Share this article