Vercel
intermediate
30-35 minutes
Serverless & Edge Functions
Build backend APIs with Vercel Serverless Functions. Learn when to use Node.js runtime vs Edge runtime.
What you'll accomplish
API routes deployed as serverless functions with proper error handling
Prerequisites
- Project deployed to Vercel
- Basic understanding of APIs
What Are Serverless Functions?
Serverless functions are backend code that runs on-demand without managing servers.
User Request → Vercel → Spin up function → Execute → Return response → Shut down
Benefits:
- No server management - Vercel handles scaling
- Pay per use - Only pay when functions run
- Auto-scaling - Handles traffic spikes automatically
- Global deployment - Functions run near your users
Runtimes: Node.js vs Edge
Vercel offers two runtimes:
| Feature | Node.js Runtime | Edge Runtime |
|---|---|---|
| Cold starts | ~250ms | ~0ms |
| Max duration | 10-900s | 30s |
| Location | Single region | Globally distributed |
| Node.js APIs | Full access | Limited subset |
| NPM packages | All | Web-compatible only |
| Memory | Up to 3GB | 128MB |
| Database access | Any | Edge-compatible only |
When to Use Each
Node.js Runtime:
- Database queries
- File system access
- Heavy computation
- Any npm package
Edge Runtime:
- Low-latency responses
- Simple transformations
- Authentication checks
- A/B testing
- Geolocation-based logic
Creating Functions (Next.js)
App Router (Recommended)
Create files in app/api/:
typescript// app/api/hello/route.ts export async function GET() { return Response.json({ message: 'Hello, World!' }); } export async function POST(request: Request) { const body = await request.json(); return Response.json({ received: body }); }
Pages Router
Create files in pages/api/:
typescript// pages/api/hello.ts import type { NextApiRequest, NextApiResponse } from 'next'; export default function handler( req: NextApiRequest, res: NextApiResponse ) { res.status(200).json({ message: 'Hello, World!' }); }
Edge Functions
App Router
typescript// app/api/edge/route.ts export const runtime = 'edge'; export async function GET(request: Request) { const { searchParams } = new URL(request.url); const name = searchParams.get('name') || 'World'; return Response.json({ message: `Hello, ${name}!`, region: process.env.VERCEL_REGION, }); }
Pages Router
typescript// pages/api/edge.ts import type { NextRequest } from 'next/server'; export const config = { runtime: 'edge', }; export default function handler(req: NextRequest) { return new Response( JSON.stringify({ message: 'Hello from the edge!' }), { status: 200, headers: { 'Content-Type': 'application/json' }, } ); }
Request Handling
Reading Request Data
typescript// app/api/users/route.ts export async function POST(request: Request) { // JSON body const body = await request.json(); // Form data const formData = await request.formData(); const name = formData.get('name'); // URL parameters const { searchParams } = new URL(request.url); const id = searchParams.get('id'); // Headers const authHeader = request.headers.get('Authorization'); return Response.json({ success: true }); }
Dynamic Route Parameters
typescript// app/api/users/[id]/route.ts export async function GET( request: Request, { params }: { params: { id: string } } ) { const userId = params.id; // Fetch user from database const user = await getUser(userId); if (!user) { return Response.json( { error: 'User not found' }, { status: 404 } ); } return Response.json(user); }
Catch-All Routes
typescript// app/api/[...path]/route.ts export async function GET( request: Request, { params }: { params: { path: string[] } } ) { // /api/a/b/c → path = ['a', 'b', 'c'] const fullPath = params.path.join('/'); return Response.json({ path: fullPath }); }
Response Patterns
JSON Response
typescriptreturn Response.json( { data: 'value' }, { status: 200 } );
Custom Headers
typescriptreturn new Response(JSON.stringify({ data: 'value' }), { status: 200, headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=60', 'X-Custom-Header': 'value', }, });
Streaming Response
typescriptexport async function GET() { const encoder = new TextEncoder(); const stream = new ReadableStream({ async start(controller) { for (let i = 0; i < 5; i++) { controller.enqueue(encoder.encode(`data: ${i}\n\n`)); await new Promise(r => setTimeout(r, 1000)); } controller.close(); }, }); return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', }, }); }
Redirect
typescriptimport { redirect } from 'next/navigation'; export async function GET() { redirect('/new-location'); } // Or manual: return Response.redirect('https://example.com', 302);
Error Handling
Proper Error Responses
typescript// app/api/users/[id]/route.ts export async function GET( request: Request, { params }: { params: { id: string } } ) { try { const user = await getUser(params.id); if (!user) { return Response.json( { error: 'User not found' }, { status: 404 } ); } return Response.json(user); } catch (error) { console.error('Database error:', error); return Response.json( { error: 'Internal server error' }, { status: 500 } ); } }
Validation
typescriptimport { z } from 'zod'; const UserSchema = z.object({ name: z.string().min(1), email: z.string().email(), }); export async function POST(request: Request) { const body = await request.json(); const result = UserSchema.safeParse(body); if (!result.success) { return Response.json( { error: 'Validation failed', details: result.error.issues }, { status: 400 } ); } // Create user with validated data const user = await createUser(result.data); return Response.json(user, { status: 201 }); }
Database Connections
Connection Pooling
For serverless, use connection pooling to avoid exhausting database connections:
typescript// lib/db.ts import { Pool } from 'pg'; // Create pool once, reuse across invocations const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 10, }); export async function query(text: string, params?: any[]) { const client = await pool.connect(); try { return await client.query(text, params); } finally { client.release(); } }
Prisma with Accelerate
typescript// lib/prisma.ts import { PrismaClient } from '@prisma/client'; import { withAccelerate } from '@prisma/extension-accelerate'; const prisma = new PrismaClient().$extends(withAccelerate()); export default prisma;
Edge-Compatible Databases
For Edge runtime, use:
- Vercel Postgres
- Vercel KV
- PlanetScale
- Turso (LibSQL)
- Upstash Redis
- Neon (with serverless driver)
Middleware
Run code before every request:
typescript// middleware.ts (at project root) import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; export function middleware(request: NextRequest) { // Check authentication const token = request.cookies.get('token'); if (!token && request.nextUrl.pathname.startsWith('/dashboard')) { return NextResponse.redirect(new URL('/login', request.url)); } // Add headers const response = NextResponse.next(); response.headers.set('X-Request-Id', crypto.randomUUID()); return response; } export const config = { matcher: ['/dashboard/:path*', '/api/:path*'], };
Configuration
In vercel.json
json{ "functions": { "app/api/**/*.ts": { "memory": 1024, "maxDuration": 30 }, "app/api/heavy-task/route.ts": { "memory": 3008, "maxDuration": 60 } } }
Memory Options
128,256,512,1024,2048,3008MB
Max Duration by Plan
| Plan | Max Duration |
|---|---|
| Hobby | 10 seconds |
| Pro | 60 seconds |
| Enterprise | 900 seconds |
Common Patterns
API Key Authentication
typescriptexport async function GET(request: Request) { const apiKey = request.headers.get('x-api-key'); if (apiKey !== process.env.API_SECRET_KEY) { return Response.json( { error: 'Unauthorized' }, { status: 401 } ); } return Response.json({ data: 'protected' }); }
Rate Limiting
typescriptimport { Ratelimit } from '@upstash/ratelimit'; import { Redis } from '@upstash/redis'; const ratelimit = new Ratelimit({ redis: Redis.fromEnv(), limiter: Ratelimit.slidingWindow(10, '10 s'), }); export async function GET(request: Request) { const ip = request.headers.get('x-forwarded-for') ?? 'anonymous'; const { success } = await ratelimit.limit(ip); if (!success) { return Response.json( { error: 'Rate limit exceeded' }, { status: 429 } ); } return Response.json({ data: 'success' }); }
CORS
typescriptconst corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', }; export async function OPTIONS() { return new Response(null, { headers: corsHeaders }); } export async function GET() { return Response.json( { data: 'value' }, { headers: corsHeaders } ); }
Testing Locally
bash# Start local dev server vercel dev # Test your function curl http://localhost:3000/api/hello curl -X POST http://localhost:3000/api/users -d '{"name":"John"}'
Monitoring
View function logs in:
- Vercel Dashboard → Project → Logs
- CLI:
vercel logs --follow
bash# View recent logs vercel logs # Stream logs in real-time vercel logs --follow # Filter by path vercel logs /api/users
Note
Checkpoint: Create an API route that handles GET and POST requests, returns proper error codes, and test it locally with vercel dev.