Skip to main content

Overview

This guide covers everything you need to protect your Next.js application with Unkey, including App Router API routes, Pages Router endpoints, and reusable middleware patterns. What you’ll learn:
  • Quick setup with @unkey/nextjs wrapper
  • Manual verification for full control
  • Reusable middleware patterns
  • Permission-based access control
  • Best practices for production

Prerequisites

Quick Start with @unkey/nextjs

The fastest way to get started is with the official Next.js wrapper.
1

Install the SDK

npm install @unkey/nextjs
2

Add your root key

Get a root key from Settings → Root Keys and add it to your environment:
.env.local
UNKEY_ROOT_KEY="unkey_..."
Never commit your root key. Add .env.local to .gitignore.
3

Create a protected App Router route

app/api/protected/route.ts
import { NextRequestWithUnkeyContext, withUnkey } from "@unkey/nextjs";

export const POST = withUnkey(
  async (req: NextRequestWithUnkeyContext) => {
    // The key has already been verified at this point
    // Access verification details via req.unkey.data
        
    // Your API logic here
    return Response.json({
      message: "Hello!",
      keyId: req.unkey.data.keyId,
      // If you set an externalId when creating the key:
      externalId: req.unkey.data.identity?.externalId,
    });
  },
  { rootKey: process.env.UNKEY_ROOT_KEY! }
);
The withUnkey wrapper handles key extraction, verification, and error responses automatically. Invalid keys never reach your handler.
4

Test it

First, create a test key in your Unkey dashboard, then:
Test with valid key
curl -X POST http://localhost:3000/api/protected \
  -H "Authorization: Bearer YOUR_API_KEY"
You should see:
{
  "message": "Hello!",
  "keyId": "key_..."
}
Now try without a key:
Test without key
curl -X POST http://localhost:3000/api/protected
You’ll get a 401 Unauthorized response.

Pages Router

For Next.js Pages Router, wrap your API handler:
pages/api/protected.ts
import { withUnkey, NextRequestWithUnkeyContext } from "@unkey/nextjs";
import { NextApiRequest, NextApiResponse } from "next";

async function handler(req: NextApiRequest, res: NextApiResponse) {
  // Type assertion needed for Pages Router
  const unkeyReq = req as unknown as NextRequestWithUnkeyContext;
  
  return res.json({
    message: "Access granted",
    user: unkeyReq.unkey?.data.identity?.externalId,
  });
}

export default withUnkey(handler, { rootKey: process.env.UNKEY_ROOT_KEY! });

Custom Key Extraction

By default, withUnkey looks for a Bearer token in the Authorization header. Customize this:
export const POST = withUnkey(
  async (req) => {
    return Response.json({ message: "Success" });
  },
  {
    rootKey: process.env.UNKEY_ROOT_KEY!,
    getKey: (req) => req.headers.get("x-api-key"),  // Custom header
  }
);

Custom Error Handling

export const POST = withUnkey(
  async (req) => {
    return Response.json({ message: "Success" });
  },
  {
    rootKey: process.env.UNKEY_ROOT_KEY!,
    handleInvalidKey: (req, result) => {
      // result.code tells you why it failed
      return Response.json(
        { 
          error: "Unauthorized",
          reason: result.code,  // "NOT_FOUND", "EXPIRED", "RATE_LIMITED", etc.
        },
        { status: 401 }
      );
    },
    onError: (req, error) => {
      console.error("Unkey error:", error);
      return Response.json(
        { error: "Authentication service unavailable" },
        { status: 503 }
      );
    },
  }
);

Manual Verification

For full control over the authentication flow:
app/api/protected/route.ts
import { Unkey } from "@unkey/api";
import { UnkeyError } from "@unkey/api/models/errors";
import { NextRequest, NextResponse } from "next/server";

const unkey = new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY! });

export async function POST(req: NextRequest) {
  // 1. Extract API key
  const authHeader = req.headers.get("authorization");
  if (!authHeader?.startsWith("Bearer ")) {
    return NextResponse.json(
      { error: "Missing API key" },
      { status: 401 }
    );
  }
  const apiKey = authHeader.slice(7);

  // 2. Verify with Unkey
  try {
    const { meta, data } = await unkey.keys.verifyKey({
      key: apiKey,
    });

    if (!data.valid) {
      return Response.json({ error: "Invalid API key" }, { status: 401 });
    }

    // 3. Your API logic here
    return Response.json({ data: "hello world" });
  } catch (err) {
    if (err instanceof UnkeyError) {
      console.error("Unkey API Error:", {
        statusCode: err.statusCode,
        body: err.body,
        message: err.message,
      });
      return Response.json(
        { error: "API error occurred", details: err.message },
        { status: err.statusCode },
      );
    }

    console.log("Unknown error:", err);
    return Response.json({ error: "Internal Server Error" }, { status: 500 });
  }
}

Reusable Middleware Pattern

Create a helper for consistent auth across routes:
lib/auth.ts
import { Unkey } from "@unkey/api";
import { UnkeyError } from "@unkey/api/models/errors";
import { NextRequest, NextResponse } from "next/server";
import { V2KeysVerifyKeyResponseData } from "@unkey/api/models/components";

type AuthenticatedHandler = (
  req: NextRequest,
  auth: V2KeysVerifyKeyResponseData,
) => Promise<NextResponse>;

const unkey = new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY! });

export function withAuth(handler: AuthenticatedHandler) {
  return async (req: NextRequest) => {
    const authHeader = req.headers.get("authorization");

    if (!authHeader?.startsWith("Bearer ")) {
      return NextResponse.json({ error: "Missing API key" }, { status: 401 });
    }

    try {
      const { meta, data } = await unkey.keys.verifyKey({
        key: authHeader.slice(7),
      });

      if (!data.valid) {
        return NextResponse.json({ error: data.code }, { status: 401 });
      }

      return handler(req, data);
    } catch (error) {
      if (error instanceof UnkeyError) {
        return NextResponse.json(
          { error: error.message },
          { status: error.statusCode },
        );
      }
      return NextResponse.json(
        { error: "Service unavailable" },
        { status: 503 },
      );
    }
  };
}
Use it in your routes:
app/api/users/route.ts
import { withAuth } from "@/lib/auth";

export const GET = withAuth(async (req, auth) => {
  // auth contains the full verification result
  const users = await db.users.findMany({
    where: { organizationId: auth.identity?.externalId },
  });
  
  return Response.json({ users });
});

Permission-Based Access Control

Require specific permissions for sensitive endpoints:
app/api/admin/route.ts
import { Unkey } from "@unkey/api";
import { UnkeyError } from "@unkey/api/models/errors";
import { NextRequest, NextResponse } from "next/server";

const unkey = new Unkey({ rootKey: process.env.UNKEY_ROOT_KEY! });

export async function POST(req: NextRequest) {
  const authHeader = req.headers.get("authorization");
  if (!authHeader?.startsWith("Bearer ")) {
    return NextResponse.json(
      { error: "Missing API key" },
      { status: 401 }
    );
  }

  const apiKey = authHeader.slice(7);

  try {
    const result = await unkey.keys.verifyKey({
      key: apiKey,
      permissions: ["admin.delete"],
    });

    if (!result.data.valid) {
      if (result.data.code === "INSUFFICIENT_PERMISSIONS") {
        return Response.json({ error: "Admin access required" }, { status: 403 });
      }
      return Response.json({ error: "Unauthorized" }, { status: 401 });
    }

    return Response.json({ data: "hello world" });
  } catch (err) {
    if (err instanceof UnkeyError) {
      console.error("Unkey API Error:", {
        statusCode: err.statusCode,
        body: err.body,
        message: err.message,
      });
      return Response.json(
        { error: "API error occurred", details: err.message },
        { status: err.statusCode },
      );
    }

    console.log("Unknown error:", err);
    return Response.json({ error: "Internal Server Error" }, { status: 500 });
  }
}

Verification Response Data

After verification, req.unkey.data (or data in manual mode) contains:
FieldTypeDescription
validbooleanWhether the key passed all checks
codestringStatus code (VALID, NOT_FOUND, RATE_LIMITED, etc.)
keyIdstringThe key’s unique identifier
namestring?Human-readable name of the key
metaobject?Custom metadata associated with the key
expiresnumber?Unix timestamp (in milliseconds) when the key will expire
creditsnumber?Remaining uses (if usage limits set)
enabledbooleanWhether the key is enabled
rolesstring[]?Permissions attached to the key
permissionsstring[]?Permissions attached to the key
identityobject?Identity info if externalId was set
ratelimitsobject[]?Rate limit states (if rate limiting configured)

Next Steps

Add rate limiting

Limit requests per key

Set usage limits

Cap total requests per key

Add permissions

Fine-grained access control

Next.js SDK Reference

Full SDK documentation

Troubleshooting

  • Ensure the key hasn’t expired or been revoked
  • Verify the Authorization header format: Bearer YOUR_KEY (note the space)
  • Check that your root key has the verify_key permission
  • Restart your dev server after adding .env.local
  • Make sure the file is in your project root
  • Check for typos in the variable name

Build docs developers (and LLMs) love