API Routes & BFF Pattern
Next.js API routes as a backend-for-frontend — proxying, SSE streaming, webhook receivers, and environment variable boundaries.
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 case | Use API route? | Alternative |
|---|---|---|
| Proxying to backend (avoid CORS, hide credentials) | ✅ Yes | Direct call from client if CORS is configured |
| Server-Sent Events (SSE) to the browser | ✅ Yes | — |
| Receiving webhooks from external services | ✅ Yes | Dedicated backend service |
| Health check endpoint | ✅ Yes | — |
| Business logic (create account, send message) | ❌ No | Call backend API directly from clientDomains/api.ts |
| Database access | ❌ No | Dedicated 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 prefix | Accessible in | Example |
|---|---|---|
NEXT_PUBLIC_ | Client + server | NEXT_PUBLIC_API_URL |
| (no prefix) | Server only | STRIPE_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.