7 min read

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:

  1. Verifying a JWT‑based session at the edge.
  2. Fetching protected data from an internal API without exposing credentials.
  3. Returning the data to the client in a way that works with Next.js’ app router 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?

  1. Token extraction – Supports both Authorization: Bearer … and an auth_token cookie (common for SPA auth flows).
  2. Verification – Uses the jose library, which works in the edge runtime because it’s pure‑JS.
  3. 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.
  4. Redirect on failure – Keeps the UI simple; you could also return 401 for 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/fetch is 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:

  1. Store a refresh token in an HttpOnly cookie (refresh_token).
  2. In middleware.ts, when the access token is expired, call an auth refresh endpoint (also an Edge Function) using the refresh token.
  3. Set a new auth_token cookie 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/api package (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 fetch call 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-id header set by middleware.
  • Edge Caching – For public data, add Cache-Control: public, max-age=60 in the response; for private data, always use no-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:

  1. Verifying JWTs in Edge Middleware,
  2. Enriching the request with user metadata,
  3. Calling internal services from Edge API Routes using secret‑only environment variables, and
  4. 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!