> Markdown version of https://authpi.com/docs/quickstarts/nextjs/ — fetch the complete AuthPI docs index at https://authpi.com/llms.txt to discover all available pages.

# Add authentication to a Next.js app

Add authentication to your [Next.js](https://nextjs.org) app with AuthPI — using the AuthPI SDK or any OIDC client library.

## Overview

This guide walks through adding AuthPI authentication to a Next.js App Router application. Every code section shows two approaches side by side:

- **@authpi/idp** — the official AuthPI SDK with built-in PKCE, token refresh, and agent management.
- **arctic** — a lightweight, generic OAuth 2.0 client where you wire up endpoints yourself.

Both implement the Authorization Code flow with PKCE (S256).

### Prerequisites

- An AuthPI account with an **Issuer** configured
- A registered **Client** (Web type) with redirect URI set to `http://localhost:3000/api/auth/callback`
- Your **Client ID** and **Client Secret** noted
- A test **User** created in your Issuer

## Install

**@authpi/idp:**
```bash
npm install @authpi/idp
```

**arctic:**
```bash
npm install arctic
```

## Environment variables

Create a `.env.local` file in your project root:

```bash
AUTHPI_ISSUER_URL=https://idp.authpi.com/iss_your_issuer_id
AUTHPI_CLIENT_ID=cli_your_client_id
AUTHPI_CLIENT_SECRET=cli_secret_your_client_secret
NEXT_PUBLIC_BASE_URL=http://localhost:3000
```

## Configure the client

**@authpi/idp:**
```typescript
// lib/auth.ts
import { IdpClient } from "@authpi/idp";

export const authClient = new IdpClient({
  issuerUrl: process.env.AUTHPI_ISSUER_URL!,
  clientId: process.env.AUTHPI_CLIENT_ID!,
  clientSecret: process.env.AUTHPI_CLIENT_SECRET!,
  redirectUri: `${process.env.NEXT_PUBLIC_BASE_URL}/api/auth/callback`,
});
```

**arctic:**
```typescript
// lib/auth.ts
import * as arctic from "arctic";

export const issuerUrl = process.env.AUTHPI_ISSUER_URL!;

export const oauthClient = new arctic.OAuth2Client(
  process.env.AUTHPI_CLIENT_ID!,
  process.env.AUTHPI_CLIENT_SECRET!,
  `${process.env.NEXT_PUBLIC_BASE_URL}/api/auth/callback`
);
```

## Login route

Redirect the user to AuthPI's authorization endpoint. Store the PKCE code verifier and state in httpOnly cookies so they survive the redirect.

**@authpi/idp:**
```typescript
// app/api/auth/login/route.ts
import { NextResponse } from "next/server";
import { authClient } from "@/lib/auth";

export async function GET() {
  const { url, codeVerifier, state } = await authClient.createAuthorizationUrl({
    scopes: ["openid", "profile", "email"],
  });

  const response = NextResponse.redirect(url);

  response.cookies.set("code_verifier", codeVerifier, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    path: "/",
    maxAge: 60 * 10,
    sameSite: "lax",
  });

  response.cookies.set("oauth_state", state, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    path: "/",
    maxAge: 60 * 10,
    sameSite: "lax",
  });

  return response;
}
```

**arctic:**
```typescript
// app/api/auth/login/route.ts
import { NextResponse } from "next/server";
import * as arctic from "arctic";
import { oauthClient, issuerUrl } from "@/lib/auth";

export async function GET() {
  const state = arctic.generateState();
  const codeVerifier = arctic.generateCodeVerifier();

  const url = oauthClient.createAuthorizationURLWithPKCE(
    `${issuerUrl}/authorize`,
    state,
    arctic.CodeChallengeMethod.S256,
    codeVerifier,
    ["openid", "profile", "email"]
  );

  const response = NextResponse.redirect(url);

  response.cookies.set("code_verifier", codeVerifier, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    path: "/",
    maxAge: 60 * 10,
    sameSite: "lax",
  });

  response.cookies.set("oauth_state", state, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    path: "/",
    maxAge: 60 * 10,
    sameSite: "lax",
  });

  return response;
}
```

## Callback route

Exchange the authorization code for tokens, then store them in a session cookie. In production you should encrypt this cookie — here we keep it simple with JSON.

**@authpi/idp:**
```typescript
// app/api/auth/callback/route.ts
import { NextRequest, NextResponse } from "next/server";
import { authClient } from "@/lib/auth";

export async function GET(request: NextRequest) {
  const url = new URL(request.url);
  const code = url.searchParams.get("code");
  const state = url.searchParams.get("state");

  const storedState = request.cookies.get("oauth_state")?.value;
  const codeVerifier = request.cookies.get("code_verifier")?.value;

  if (!code || !state || !storedState || !codeVerifier || state !== storedState) {
    return NextResponse.json({ error: "Invalid callback parameters" }, { status: 400 });
  }

  const agent = await authClient.exchangeCode(code, codeVerifier);

  const response = NextResponse.redirect(new URL("/dashboard", request.url));

  response.cookies.set("session", JSON.stringify(agent.tokens), {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    path: "/",
    maxAge: 60 * 60 * 24 * 7, // 7 days
    sameSite: "lax",
  });

  response.cookies.delete("code_verifier");
  response.cookies.delete("oauth_state");

  return response;
}
```

**arctic:**
```typescript
// app/api/auth/callback/route.ts
import { NextRequest, NextResponse } from "next/server";
import { oauthClient, issuerUrl } from "@/lib/auth";

export async function GET(request: NextRequest) {
  const url = new URL(request.url);
  const code = url.searchParams.get("code");
  const state = url.searchParams.get("state");

  const storedState = request.cookies.get("oauth_state")?.value;
  const codeVerifier = request.cookies.get("code_verifier")?.value;

  if (!code || !state || !storedState || !codeVerifier || state !== storedState) {
    return NextResponse.json({ error: "Invalid callback parameters" }, { status: 400 });
  }

  const tokens = await oauthClient.validateAuthorizationCode(
    `${issuerUrl}/token`,
    code,
    codeVerifier
  );

  const sessionData = {
    accessToken: tokens.accessToken(),
    refreshToken: tokens.refreshToken(),
    idToken: tokens.idToken(),
    expiresAt: tokens.accessTokenExpiresAt().toISOString(),
  };

  const response = NextResponse.redirect(new URL("/dashboard", request.url));

  response.cookies.set("session", JSON.stringify(sessionData), {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    path: "/",
    maxAge: 60 * 60 * 24 * 7, // 7 days
    sameSite: "lax",
  });

  response.cookies.delete("code_verifier");
  response.cookies.delete("oauth_state");

  return response;
}
```

## Protected page

Read the session cookie on the server and display user information. The AuthPI SDK can reconstitute an agent from stored tokens and will auto-refresh expired access tokens. With arctic, you fetch the userinfo endpoint manually.

**@authpi/idp:**
```typescript
// app/dashboard/page.tsx
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { authClient } from "@/lib/auth";

export default async function DashboardPage() {
  const cookieStore = await cookies();
  const sessionCookie = cookieStore.get("session")?.value;

  if (!sessionCookie) {
    redirect("/api/auth/login");
  }

  const tokens = JSON.parse(sessionCookie);
  const agent = await authClient.createAgent(tokens);

  return (
    <main>
      <h1>Dashboard</h1>
      <p>User ID: {agent.id}</p>
      <p>Email: {agent.email}</p>
      <a href="/api/auth/logout">Log out</a>
    </main>
  );
}
```

**arctic:**
```typescript
// app/dashboard/page.tsx
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { issuerUrl } from "@/lib/auth";

export default async function DashboardPage() {
  const cookieStore = await cookies();
  const sessionCookie = cookieStore.get("session")?.value;

  if (!sessionCookie) {
    redirect("/api/auth/login");
  }

  const session = JSON.parse(sessionCookie);

  const response = await fetch(`${issuerUrl}/userinfo`, {
    headers: { Authorization: `Bearer ${session.accessToken}` },
  });

  if (!response.ok) {
    redirect("/api/auth/login");
  }

  const user = await response.json();

  return (
    <main>
      <h1>Dashboard</h1>
      <p>User ID: {user.sub}</p>
      <p>Email: {user.email}</p>
      <a href="/api/auth/logout">Log out</a>
    </main>
  );
}
```

## Logout route

Revoke the refresh token, clear the session cookie, and redirect to AuthPI's logout endpoint so the IdP session is also terminated.

**@authpi/idp:**
```typescript
// app/api/auth/logout/route.ts
import { NextRequest, NextResponse } from "next/server";
import { authClient } from "@/lib/auth";

export async function GET(request: NextRequest) {
  const sessionCookie = request.cookies.get("session")?.value;

  if (sessionCookie) {
    const tokens = JSON.parse(sessionCookie);

    if (tokens.refreshToken) {
      await authClient.revokeToken(tokens.refreshToken, "refresh_token");
    }
  }

  const logoutUrl = authClient.createLogoutUrl({
    postLogoutRedirectUri: `${process.env.NEXT_PUBLIC_BASE_URL}`,
  });

  const response = NextResponse.redirect(logoutUrl);
  response.cookies.delete("session");

  return response;
}
```

**arctic:**
```typescript
// app/api/auth/logout/route.ts
import { NextRequest, NextResponse } from "next/server";
import { oauthClient, issuerUrl } from "@/lib/auth";

export async function GET(request: NextRequest) {
  const sessionCookie = request.cookies.get("session")?.value;

  if (sessionCookie) {
    const session = JSON.parse(sessionCookie);

    if (session.refreshToken) {
      await oauthClient.revokeToken(
        `${issuerUrl}/revoke`,
        session.refreshToken
      );
    }
  }

  const logoutUrl = new URL(`${issuerUrl}/logout`);
  logoutUrl.searchParams.set(
    "post_logout_redirect_uri",
    process.env.NEXT_PUBLIC_BASE_URL!
  );

  const response = NextResponse.redirect(logoutUrl.toString());
  response.cookies.delete("session");

  return response;
}
```

## Next steps

You now have a working login/logout flow. From here you can:

- Add [Organizations](/docs/concepts/organizations) support for multi-tenant access control
- Subscribe to [Events](/docs/concepts/events) for audit logging
- Set up [Webhooks](/docs/guides/webhooks) for real-time notifications when users sign up, update their profile, or change authentication methods

The AuthPI SDK handles token refresh automatically via `createAgent()`. If you are using arctic, you will need to implement refresh logic manually by calling the `/token` endpoint with `grant_type=refresh_token` when the access token expires.