Serverless functions with Next.js best practices
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:
- Group related endpoints by resource (e.g.,
/api/users
,/api/products
) - Use HTTP methods appropriately (GET for reads, POST for creates, etc.)
- Keep individual functions focused on a single responsibility
- 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:
- Tree-shaking: Only import what you need
- Externalize dependencies: Move large, rarely changing dependencies to layers (if supported by your deployment platform)
- 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.