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:

FeatureNode.js RuntimeEdge Runtime
Cold starts~250ms~0ms
Max duration10-900s30s
LocationSingle regionGlobally distributed
Node.js APIsFull accessLimited subset
NPM packagesAllWeb-compatible only
MemoryUp to 3GB128MB
Database accessAnyEdge-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

typescript
return Response.json(
  { data: 'value' },
  { status: 200 }
);

Custom Headers

typescript
return new Response(JSON.stringify({ data: 'value' }), {
  status: 200,
  headers: {
    'Content-Type': 'application/json',
    'Cache-Control': 'max-age=60',
    'X-Custom-Header': 'value',
  },
});

Streaming Response

typescript
export 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

typescript
import { 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

typescript
import { 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, 3008 MB

Max Duration by Plan

PlanMax Duration
Hobby10 seconds
Pro60 seconds
Enterprise900 seconds

Common Patterns

API Key Authentication

typescript
export 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

typescript
import { 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

typescript
const 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.