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:
Request Interception
Any request to /api/* (except /api/auth/*) is caught by this handler.
Session Validation
The handler validates the current user’s session using Better Auth.
JWT Generation
A JWT token is generated for the authenticated user.
Token Injection
The JWT is injected into the Authorization header as a Bearer token.
Request Forwarding
The request is forwarded to your backend API with all original headers, query parameters, and body intact.
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:
BACKEND_API_URL = http://localhost:8080
In 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 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