Skip to main content
The API proxy is a Next.js catch-all route that automatically forwards requests from your frontend to your backend API with JWT authentication. It solves common challenges like CORS, credential injection, and backend URL privacy.

How It Works

The proxy route is located at src/app/api/[...path]/route.ts and handles all HTTP methods:
1

Request Interception

Any request to /api/* (except /api/auth/*) is caught by this handler.
2

Session Validation

The handler validates the current user’s session using Better Auth.
3

JWT Generation

A JWT token is generated for the authenticated user.
4

Token Injection

The JWT is injected into the Authorization header as a Bearer token.
5

Request Forwarding

The request is forwarded to your backend API with all original headers, query parameters, and body intact.
6

Response Streaming

The backend response is streamed back to the client as-is.

Implementation

Here’s the complete proxy implementation:
src/app/api/[...path]/route.ts
import { auth } from '@/lib/auth'
import { headers } from 'next/headers'
import { NextRequest, NextResponse } from 'next/server'

/** The backend API URL — server-side only, not exposed to the browser */
const BACKEND_API_URL = process.env.BACKEND_API_URL || 'http://localhost:8080'

/**
 * Universal handler for all HTTP methods.
 * Proxies the request to the backend with JWT authentication.
 */
async function handler(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
  // Reconstruct the target path from the catch-all segments
  const path = await params.then(x => x.path.join('/'))

  // Build the full backend URL, preserving query parameters
  const url = new URL(`/api/${path}`, BACKEND_API_URL)
  request.nextUrl.searchParams.forEach((value, key) => {
    url.searchParams.append(key, value)
  })

  // Clone request headers and remove the host header (will be set by fetch)
  const rheaders = new Headers(request.headers)
  rheaders.delete('host')

  try {
    // Get a JWT from better-auth for the current session
    let token: string
    try {
      const result = await auth.api.getToken({
        headers: await headers(),
      })
      if (!result?.token) {
        return NextResponse.json(
          { error: 'Authentication required' },
          { status: 401 }
        )
      }
      token = result.token
    } catch {
      return NextResponse.json(
        { error: 'Authentication required — please sign in' },
        { status: 401 }
      )
    }

    // Inject the JWT as a Bearer token
    rheaders.set("Authorization", `Bearer ${token}`)

    // Build fetch options — omit body for GET/HEAD (throws in strict runtimes)
    const isBodyless = request.method === 'GET' || request.method === 'HEAD'
    const fetchOptions: RequestInit = {
      method: request.method,
      headers: rheaders,
      ...(isBodyless ? {} : { body: request.body, duplex: 'half' }),
    }

    // Forward the request to the backend
    const response = await fetch(url.toString(), fetchOptions)

    // Clean up response headers that could conflict with Next.js streaming
    const responseHeaders = new Headers(response.headers)
    responseHeaders.delete('content-encoding')
    responseHeaders.delete('content-length')
    responseHeaders.delete('transfer-encoding')

    return new NextResponse(response.body, {
      status: response.status,
      statusText: response.statusText,
      headers: responseHeaders,
    })
  } catch (error) {
    console.error('Proxy error:', error)
    return NextResponse.json(
      { error: 'Failed to proxy request' },
      { status: 500 }
    )
  }
}

// Export handler for all HTTP methods
export const GET = handler
export const POST = handler
export const PUT = handler
export const DELETE = handler
export const PATCH = handler
export const HEAD = handler

Key Features

Path Reconstruction

The catch-all route captures all path segments and reconstructs the backend URL:
const path = await params.then(x => x.path.join('/'))
const url = new URL(`/api/${path}`, BACKEND_API_URL)
Example:
  • Frontend: GET /api/users/123 → Backend: GET http://localhost:8080/api/users/123
  • Frontend: POST /api/posts → Backend: POST http://localhost:8080/api/posts

Query Parameter Preservation

All query parameters are forwarded to the backend:
request.nextUrl.searchParams.forEach((value, key) => {
  url.searchParams.append(key, value)
})
Example:
  • Frontend: /api/users?role=admin&limit=10
  • Backend: http://localhost:8080/api/users?role=admin&limit=10

JWT Injection

The proxy generates a fresh JWT for each request and injects it as a Bearer token:
const result = await auth.api.getToken({
  headers: await headers(),
})

if (!result?.token) {
  return NextResponse.json(
    { error: 'Authentication required' },
    { status: 401 }
  )
}

rheaders.set("Authorization", `Bearer ${result.token}`)

Request Body Handling

The proxy correctly handles request bodies for all HTTP methods:
const isBodyless = request.method === 'GET' || request.method === 'HEAD'
const fetchOptions: RequestInit = {
  method: request.method,
  headers: rheaders,
  ...(isBodyless ? {} : { body: request.body, duplex: 'half' }),
}
GET and HEAD requests cannot have a body, so it’s omitted to prevent runtime errors.

Why Use a Proxy?

CORS Avoidance

Since the frontend calls the same origin (/api/*), there are no cross-origin issues. The proxy handles backend communication server-side.

Backend URL Privacy

The BACKEND_API_URL is a server-side environment variable, never exposed to the browser. Users cannot see or access your backend directly.

Centralized Auth

JWT token generation happens in one place. You don’t need to manually inject tokens in every API call throughout your frontend.

Security

JWTs are generated server-side and never exposed to client-side JavaScript, reducing XSS risks.

Usage Example

From your frontend components, make API calls as if the backend is on the same origin:
// In your React component
async function fetchUserData() {
  try {
    const response = await fetch('/api/users/me')
    
    if (!response.ok) {
      throw new Error('Failed to fetch user data')
    }
    
    const data = await response.json()
    return data
  } catch (error) {
    console.error('Error fetching user:', error)
  }
}
The proxy automatically:
  • Validates your session
  • Generates a JWT
  • Forwards the request to http://localhost:8080/api/users/me with Authorization: Bearer <token>
  • Returns the backend response
You don’t need to manually add the Authorization header or manage tokens. The proxy handles everything automatically.

Configuration

Backend API URL

Set the BACKEND_API_URL environment variable to point to your backend:
.env
BACKEND_API_URL=http://localhost:8080
In production:
.env.production
BACKEND_API_URL=https://api.yourapp.com
Use an internal/private network URL in production to prevent public access to your backend. For example, use http://backend-service:8080 in a Kubernetes cluster instead of a public URL.

Excluded Paths

The proxy does NOT handle requests to /api/auth/* — those are reserved for Better Auth endpoints. Better Auth automatically creates routes like:
  • /api/auth/sign-in
  • /api/auth/sign-up
  • /api/auth/sign-out
  • /api/auth/session
  • /api/auth/jwks
All other /api/* requests are proxied to your backend.

Security Considerations

  • JWTs are generated server-side, never exposed to client JavaScript
  • Tokens are transmitted over HTTPS in production
  • Each request gets a fresh token, minimizing the window of vulnerability
  • The backend validates JWT signatures using the JWKS endpoint
  • Set BACKEND_API_URL to an internal/private URL in production
  • Use firewall rules to restrict backend access to your frontend servers only
  • Enable rate limiting on your backend to prevent abuse
  • The proxy forwards requests as-is, so validate all input on your backend
  • Implement proper authorization checks in your backend API
  • Don’t rely solely on JWT presence — verify user permissions for each action
  • The proxy catches errors and returns a generic 500 response
  • Detailed errors are logged server-side, not exposed to clients
  • Implement proper error monitoring to catch proxy failures

Response Streaming

The proxy streams responses from the backend to support large payloads and long-running requests:
const responseHeaders = new Headers(response.headers)
responseHeaders.delete('content-encoding')
responseHeaders.delete('content-length')
responseHeaders.delete('transfer-encoding')

return new NextResponse(response.body, {
  status: response.status,
  statusText: response.statusText,
  headers: responseHeaders,
})
This enables:
  • Large file downloads without buffering the entire file in memory
  • Server-sent events (SSE) for real-time updates
  • Chunked transfer encoding for progressive rendering

Error Handling

The proxy handles several error scenarios:

No Session (401)

if (!result?.token) {
  return NextResponse.json(
    { error: 'Authentication required' },
    { status: 401 }
  )
}
If the user is not signed in, the proxy returns a 401 response immediately.

Proxy Failure (500)

catch (error) {
  console.error('Proxy error:', error)
  return NextResponse.json(
    { error: 'Failed to proxy request' },
    { status: 500 }
  )
}
If the backend is unreachable or returns an error, the proxy logs the error and returns a generic 500 response.

Alternatives to the Proxy

While the proxy is recommended, you could alternatively:

Direct Backend Calls

Call your backend API directly from the frontend:
const token = await authClient.getToken()
const response = await fetch('https://api.yourapp.com/users/me', {
  headers: {
    'Authorization': `Bearer ${token}`
  }
})
Drawbacks:
  • Exposes backend URL to clients
  • Requires CORS configuration
  • JWT tokens exposed to client-side JavaScript
  • Must manually inject tokens in every request

Next.js Server Actions

Use Server Actions for backend communication:
'use server'

export async function getUserData() {
  const token = await auth.api.getToken({ headers: await headers() })
  const response = await fetch(`${process.env.BACKEND_API_URL}/api/users/me`, {
    headers: { 'Authorization': `Bearer ${token}` }
  })
  return response.json()
}
Drawbacks:
  • More boilerplate for each endpoint
  • Harder to use with existing REST clients
  • Less flexible for complex request/response handling
The API proxy provides the best balance of security, simplicity, and developer experience for most applications.

Next Steps

JWT Tokens

Learn how JWT tokens are generated and validated

Authentication

Understand how sessions are created and managed

Build docs developers (and LLMs) love