Documentation

API Routes & BFF Pattern

Next.js API routes as a backend-for-frontend — proxying, SSE streaming, webhook receivers, and environment variable boundaries.

Show:

Framework implementations

Abstract reference: 07-api-design/06-integration-patterns.md

Next.js API routes (app/api/**/route.ts) serve as a thin backend-for-frontend (BFF) layer. They are not a replacement for a dedicated backend service — they handle concerns specific to the frontend's needs.


When to Use API Routes

Use caseUse API route?Alternative
Proxying to backend (avoid CORS, hide credentials)✅ YesDirect call from client if CORS is configured
Server-Sent Events (SSE) to the browser✅ Yes
Receiving webhooks from external services✅ YesDedicated backend service
Health check endpoint✅ Yes
Business logic (create account, send message)❌ NoCall backend API directly from clientDomains/api.ts
Database access❌ NoDedicated backend service

Basic Route Handler

// app/api/health/route.ts
import { NextResponse } from 'next/server'
 
export async function GET() {
  return NextResponse.json({ status: 'ok', timestamp: new Date().toISOString() })
}

Proxy Route

Proxies requests from the browser to a backend service, adding auth headers from the server side:

// app/api/proxy/accounts/route.ts
import { NextRequest, NextResponse } from 'next/server'
 
const BACKEND_URL = process.env.ACCOUNT_API_URL  // Server-side env var — not exposed to browser
 
export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url)
  const token = request.cookies.get('auth_token')?.value
 
  const response = await fetch(`${BACKEND_URL}/api/v1/accounts?${searchParams}`, {
    headers: {
      Authorization: `Bearer ${token}`,
      'Content-Type': 'application/json',
    },
  })
 
  const data = await response.json()
  return NextResponse.json(data, { status: response.status })
}

Server-Sent Events (SSE)

SSE is the primary real-time mechanism — simpler than WebSockets for server-to-client updates.

// app/api/events/messages/route.ts
import { NextRequest } from 'next/server'
 
const BACKEND_URL = process.env.ACCOUNT_API_URL
 
export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url)
  const accountId = searchParams.get('account_id')
  const token = request.cookies.get('auth_token')?.value
 
  // Proxy SSE stream from backend to browser
  const backendResponse = await fetch(
    `${BACKEND_URL}/api/v1/events/messages?account_id=${accountId}`,
    {
      headers: { Authorization: `Bearer ${token}` },
    }
  )
 
  // Pass through the stream
  return new Response(backendResponse.body, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    },
  })
}

Consuming SSE in a client hook:

// clientDomains/message/useMessageRealtime.ts
'use client'
 
import { useEffect, useRef } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { queryKeys } from '@/constants/queryKeys'
 
export function useMessageRealtime(accountId: string | undefined) {
  const queryClient = useQueryClient()
  const eventSourceRef = useRef<EventSource | null>(null)
 
  useEffect(() => {
    if (!accountId) return
 
    const es = new EventSource(`/api/events/messages?account_id=${accountId}`)
    eventSourceRef.current = es
 
    es.onmessage = (event) => {
      const data = JSON.parse(event.data)
      if (data.type === 'message.created') {
        // Update the React Query cache directly — no refetch needed
        queryClient.setQueryData(
          queryKeys.messages.byAccountId(accountId),
          (prev: Message[] = []) => [...prev, data.message]
        )
      }
    }
 
    es.onerror = () => es.close()
 
    return () => {
      es.close()
      eventSourceRef.current = null
    }
  }, [accountId, queryClient])
}

Webhook Receiver

// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { headers } from 'next/headers'
 
export async function POST(request: NextRequest) {
  const body = await request.text()
  const signature = headers().get('stripe-signature') ?? ''
 
  try {
    // Verify signature (implementation depends on provider)
    const event = verifyStripeWebhook(body, signature, process.env.STRIPE_WEBHOOK_SECRET!)
    await handleStripeEvent(event)
    return NextResponse.json({ received: true })
  } catch (error) {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
  }
}

Environment Variables

API routes run on the server and can access server-side environment variables safely:

Variable prefixAccessible inExample
NEXT_PUBLIC_Client + serverNEXT_PUBLIC_API_URL
(no prefix)Server onlySTRIPE_SECRET_KEY, ACCOUNT_API_URL

Use server-only variables for secrets. Never put secret keys in NEXT_PUBLIC_ variables.


Rules

  • API routes are for BFF concerns only — they do not implement business logic.
  • When calling the backend from an API route, always pass the auth token from the request (cookie or header), never from a server-side environment variable or singleton.
  • SSE endpoints proxy the stream from the backend — they do not generate events themselves.
  • All webhook endpoints return 200 immediately after signature verification, even if processing fails asynchronously.