Edge Functions with Next.js & Vercel: Secure Auth and Data Fetching Made Simple
Introduction
Edge Functions are server‑less functions that run at the CDN edge, close to the user. In the Vercel ecosystem they are called Edge Middleware and Edge API Routes. Because they execute on Vercel’s global network, they can make authentication decisions and fetch data with sub‑second latency, while keeping secrets out of the client bundle.
This article walks through a practical, production‑ready pattern for:
- Verifying a JWT‑based session at the edge.
- Fetching protected data from an internal API without exposing credentials.
- Returning the data to the client in a way that works with Next.js’
approuter and React Server Components.
You’ll see the full code for a minimal but realistic setup, learn why each piece matters, and get tips for extending the pattern to more complex scenarios.
Why Use Edge Functions for Auth & Data?
| Traditional Server‑Side Rendering (SSR) | Edge Functions |
|---|---|
Runs on a single region (e.g., us-east-1). |
Runs on the nearest Vercel edge location. |
| Higher round‑trip latency to the client. | Sub‑10 ms latency for auth checks. |
| Requires a dedicated server or container. | No server management; auto‑scaled. |
| Secrets must be bundled or fetched per request. | Secrets are injected at build time and never reach the browser. |
When you combine Next.js 13+ (with the app directory) and Vercel Edge, you get:
- Zero‑trust authentication – the request is validated before any page code runs.
- Fast data fetching – the edge can call internal services (REST, GraphQL, databases) over private VPC connections.
- Consistent API surface – the same edge route can be used by both SSR pages and client‑side fetches.
Prerequisites
- A Vercel account with a project that uses Next.js 13+.
- An authentication provider that issues JWTs (e.g., Auth0, Firebase, or a custom OIDC server).
- An internal API endpoint that requires a Bearer token (or any secret) – we’ll simulate it with a simple Vercel Serverless Function.
Note: The patterns below work with any token format (opaque, signed JWT, etc.) as long as you can verify it at the edge.
1. Storing Secrets Securely
Vercel injects environment variables into Edge Functions as read‑only values. Add the following variables in your Vercel dashboard:
| Variable | Purpose |
|---|---|
NEXT_PUBLIC_AUTH_DOMAIN |
Public URL of the auth provider (used by the client). |
AUTH_JWT_PUBLIC_KEY |
PEM‑encoded public key for verifying JWT signatures. |
INTERNAL_API_TOKEN |
Secret token used to call the internal API. |
INTERNAL_API_URL |
Base URL of the internal service (e.g., https://api.internal.example.com). |
Only NEXT_PUBLIC_* variables are exposed to the browser; the rest stay server‑only, even on the edge.
2. Edge Middleware – Guarding Every Request
Create a file middleware.ts at the project root:
// middleware.ts
import { NextResponse, NextRequest } from 'next/server';
import { jwtVerify, JWTPayload } from 'jose';
// Load the public key once per edge instance
const PUBLIC_KEY = Buffer.from(process.env.AUTH_JWT_PUBLIC_KEY ?? '', 'utf-8');
export async function middleware(req: NextRequest) {
// 1️⃣ Extract the token from the Authorization header or cookie
const authHeader = req.headers.get('authorization');
const token = authHeader?.startsWith('Bearer ')
? authHeader.substring(7)
: req.cookies.get('auth_token')?.value;
if (!token) {
// No token → redirect to login (or return 401 for API routes)
const url = req.nextUrl.clone();
url.pathname = '/login';
return NextResponse.redirect(url);
}
try {
// 2️⃣ Verify the JWT signature and expiration
const { payload } = await jwtVerify(token, PUBLIC_KEY, {
algorithms: ['RS256'],
});
// 3️⃣ Attach the payload to the request for downstream handlers
const requestHeaders = new Headers(req.headers);
requestHeaders.set('x-user-id', (payload as JWTPayload).sub ?? '');
requestHeaders.set('x-user-email', (payload as JWTPayload).email ?? '');
// Continue to the page or API route
return NextResponse.next({
request: {
// Pass the enriched headers downstream
headers: requestHeaders,
},
});
} catch (err) {
// Invalid token → force re‑login
const url = req.nextUrl.clone();
url.pathname = '/login';
return NextResponse.redirect(url);
}
}
// Apply to all routes except static assets and the login page
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|login).*)',
],
};
What’s happening?
- Token extraction – Supports both
Authorization: Bearer …and anauth_tokencookie (common for SPA auth flows). - Verification – Uses the
joselibrary, which works in the edge runtime because it’s pure‑JS. - Header enrichment – The user’s
sub(subject) and email are added as custom headers (x-user-id,x-user-email). Downstream Edge API routes can read them without re‑parsing the JWT. - Redirect on failure – Keeps the UI simple; you could also return
401for API routes.
3. Edge API Route – Secure Data Fetching
Create an Edge API route at app/api/profile/route.ts:
// app/api/profile/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { fetch } from '@vercel/fetch'; // Vercel’s edge‑compatible fetch
export const runtime = 'edge'; // <-- important!
export async function GET(req: NextRequest) {
// 1️⃣ Pull the enriched user headers from middleware
const userId = req.headers.get('x-user-id');
const userEmail = req.headers.get('x-user-email');
if (!userId) {
return new NextResponse('Unauthenticated', { status: 401 });
}
// 2️⃣ Call the internal API with the secret token
const internalRes = await fetch(`${process.env.INTERNAL_API_URL}/users/${userId}`, {
method: 'GET',
headers: {
Authorization: `Bearer ${process.env.INTERNAL_API_TOKEN}`,
'Accept': 'application/json',
},
// Edge fetch defaults to a 5‑second timeout; increase if needed
// @ts-ignore – Vercel’s edge fetch supports `cache` options
cache: 'no-store',
});
if (!internalRes.ok) {
// Propagate the error but hide internal details
return new NextResponse('Failed to load profile', { status: 502 });
}
const profile = await internalRes.json();
// 3️⃣ Return a JSON response that includes the email from the JWT
const responseBody = {
...profile,
email: userEmail,
};
return NextResponse.json(responseBody);
}
Why this works on the edge
runtime = 'edge'tells Next.js to compile the route for the Vercel Edge runtime (WebAssembly‑based, no Node APIs).@vercel/fetchis a thin wrapper that adds edge‑specific defaults (e.g., automatic DNS pre‑fetch).- No secret ever reaches the client – the internal API token lives only in the edge environment.
4. Consuming the Edge API from a Server Component
In the app/profile/page.tsx file:
// app/profile/page.tsx
import { Suspense } from 'react';
async function fetchProfile() {
const res = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/profile`, {
// `next` options tell Next.js to treat this as a server‑side fetch
next: { revalidate: 0 }, // no caching; always fresh
credentials: 'include', // forward cookies (e.g., auth_token)
});
if (!res.ok) {
throw new Error('Failed to load profile');
}
return res.json();
}
export default async function ProfilePage() {
const profile = await fetchProfile();
return (
<section>
<h1>Welcome, {profile.name}</h1>
<p>Email: {profile.email}</p>
<p>Member since: {new Date(profile.createdAt).toLocaleDateString()}</p>
</section>
);
}
Because the page is a React Server Component, the fetch runs on the edge as part of the page rendering pipeline. The client never sees the JWT or the internal API token.
5. Handling Token Refresh
Edge Middleware runs on every request, but you may want to refresh short‑lived access tokens without forcing a full login. A common pattern:
- Store a refresh token in an HttpOnly cookie (
refresh_token). - In
middleware.ts, when the access token is expired, call an auth refresh endpoint (also an Edge Function) using the refresh token. - Set a new
auth_tokencookie and continue processing.
// Inside middleware.ts – token refresh snippet
if (isExpired(payload)) {
const refreshRes = await fetch(`${process.env.NEXT_PUBLIC_AUTH_DOMAIN}/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: req.cookies.get('refresh_token')?.value }),
});
if (refreshRes.ok) {
const { accessToken } = await refreshRes.json();
// Set a new auth cookie (edge response)
const response = NextResponse.next();
response.cookies.set('auth_token', accessToken, {
httpOnly: true,
path: '/',
sameSite: 'lax',
secure: true,
});
// Re‑verify the new token (or simply attach payload)
// …
return response;
}
}
The refresh flow stays edge‑only, preserving security while keeping the UI seamless.
6. Testing Edge Functions Locally
Vercel’s CLI (vercel dev) emulates the edge runtime, but there are a few gotchas:
| Issue | Work‑around |
|---|---|
process.env not available in edge functions |
Use import.meta.env or define a .env.local file and run vercel dev --listen 3000. |
jose may try to use Node crypto |
Ensure you import the browser‑compatible build: import { jwtVerify } from 'jose/web';. |
Edge fetch does not support agent options |
Stick to standard HTTP/HTTPS URLs; avoid custom agents. |
Run:
npm i -D vercel
vercel dev
Visit http://localhost:3000/profile after logging in to see the edge‑protected page in action.
7. Scaling Considerations
- Cold starts – Edge Functions are pre‑warmed in most regions, but a sudden traffic spike can still cause a brief warm‑up. Keep the function body lightweight (avoid heavy imports).
- Rate limiting – Because the edge sits in front of your internal API, you can add a simple token bucket in middleware to protect downstream services.
- Observability – Vercel provides built‑in logs for edge functions. For richer tracing, emit OpenTelemetry spans using the
@opentelemetry/apipackage (it works in the edge runtime).
import { trace } from '@opentelemetry/api';
const tracer = trace.getTracer('edge-auth');
export async function middleware(req: NextRequest) {
return tracer.startActiveSpan('auth-middleware', async (span) => {
// existing logic …
span.setAttribute('user.id', payload.sub);
span.end();
return response;
});
}
8. Common Pitfalls & How to Avoid Them
| Symptom | Likely Cause | Fix |
|---|---|---|
jwtVerify throws Unexpected token |
Public key not PEM‑encoded or missing line breaks. | Store the key as a single‑line env var and convert with replace(/\\n/g, '\n'). |
| Edge API returns 502 even though internal API works locally | process.env.INTERNAL_API_URL points to a private VPC address not reachable from the edge. |
Use Vercel’s Private Network feature or expose the internal API via a VPC‑connected Vercel Function. |
| Cookies not sent on edge fetch | credentials: 'include' missing or cookie flagged as SameSite=Strict. |
Set SameSite=Lax for auth cookies and always include credentials. |
| Middleware runs twice (double redirect) | matcher pattern includes the login page itself. |
Exclude /login explicitly in the matcher (as shown). |
9. Extending the Pattern
- GraphQL – Replace the
fetchcall with a GraphQL client that works in the edge (e.g.,graphql-request). - Multi‑tenant apps – Store tenant‑specific secrets in Vercel’s Environment Variable Groups and read them based on the
x-tenant-idheader set by middleware. - Edge Caching – For public data, add
Cache-Control: public, max-age=60in the response; for private data, always useno-store.
Conclusion
Edge Functions give you a secure, low‑latency layer for authentication and data fetching that integrates tightly with Next.js’ server components. By:
- Verifying JWTs in Edge Middleware,
- Enriching the request with user metadata,
- Calling internal services from Edge API Routes using secret‑only environment variables, and
- Consuming the result in Server Components,
you can build modern web experiences that keep secrets on the server, reduce round‑trip time, and stay fully serverless.
Give it a try in your next Next.js project—once the pattern is in place, adding new protected endpoints is as simple as copying the edge route template and adjusting the internal API call.
Happy coding!
Member discussion