Next.js Quickstart

Integrate UniAuth into your Next.js 14+ (App Router) application with server-side token exchange, route protection via middleware, and server components that fetch the authenticated user's profile. This pattern keeps tokens server-side in httpOnly cookies for maximum security.

Prerequisites

  • Next.js 14 or later with the App Router
  • A registered UniAuth OAuth application (create one in the Developer Console)
  • Your application's client secret (available in the Developer Console after creating your app)
Why a client secret? Unlike the React SDK (which runs entirely in the browser), Next.js can exchange authorization codes on the server. Using the client secret in a server-side API route provides stronger security because the secret never reaches the browser.

Installation

npm install @uniauth/js

The @uniauth/js package provides type definitions and PKCE utilities. Server-side token exchange is done directly via fetch requests to UniAuth's token endpoint.

Environment Variables

Add the following to your .env.local file:

# Public — accessible in the browser
NEXT_PUBLIC_UNIAUTH_DOMAIN=https://uniauth.id
NEXT_PUBLIC_UNIAUTH_CLIENT_ID=your-client-id

# Private — server-side only (never exposed to the browser)
UNIAUTH_CLIENT_SECRET=your-client-secret
Security: Variables prefixed with NEXT_PUBLIC_ are bundled into client-side JavaScript. The client secret must never use this prefix.

Project Structure

Here is the file structure for the UniAuth integration:

app/
  api/
    auth/
      callback/
        route.ts          # Server-side token exchange
      logout/
        route.ts          # Clear cookies and redirect
  login/
    page.tsx              # Login page (client component)
  callback/
    page.tsx              # Callback page (client component)
  dashboard/
    page.tsx              # Protected page (server component)
  layout.tsx
lib/
  auth.ts                 # Helper to read auth cookies
middleware.ts             # Route protection
.env.local

API Route: Token Exchange

Create an API route that exchanges the authorization code for tokens on the server. This route receives the code and PKCE code verifier from the client, exchanges them with UniAuth's token endpoint using the client secret, and stores the tokens in httpOnly cookies:

// app/api/auth/callback/route.ts
import { NextRequest, NextResponse } from 'next/server';

const UNIAUTH_DOMAIN = process.env.NEXT_PUBLIC_UNIAUTH_DOMAIN!;
const CLIENT_ID = process.env.NEXT_PUBLIC_UNIAUTH_CLIENT_ID!;
const CLIENT_SECRET = process.env.UNIAUTH_CLIENT_SECRET!;

export async function POST(request: NextRequest) {
  const { code, code_verifier, redirect_uri } = await request.json();

  if (!code || !code_verifier) {
    return NextResponse.json(
      { error: 'Missing code or code_verifier' },
      { status: 400 }
    );
  }

  // Exchange authorization code for tokens
  const tokenResponse = await fetch(`${UNIAUTH_DOMAIN}/api/oauth/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code,
      redirect_uri: redirect_uri || `${request.nextUrl.origin}/callback`,
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
      code_verifier,
    }),
  });

  if (!tokenResponse.ok) {
    const error = await tokenResponse.text();
    return NextResponse.json(
      { error: 'Token exchange failed', details: error },
      { status: 401 }
    );
  }

  const tokens = await tokenResponse.json();

  // Set httpOnly cookies
  const response = NextResponse.json({ success: true });

  response.cookies.set('access_token', tokens.access_token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: tokens.expires_in,
    path: '/',
  });

  if (tokens.refresh_token) {
    response.cookies.set('refresh_token', tokens.refresh_token, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      maxAge: 30 * 24 * 60 * 60, // 30 days
      path: '/',
    });
  }

  if (tokens.id_token) {
    response.cookies.set('id_token', tokens.id_token, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      maxAge: tokens.expires_in,
      path: '/',
    });
  }

  return response;
}

Middleware for Route Protection

Create a middleware that checks for the auth cookie and redirects unauthenticated users to the login page:

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';

// Routes that require authentication
const protectedPaths = ['/dashboard', '/settings', '/profile'];

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // Check if the route requires authentication
  const isProtected = protectedPaths.some(path => pathname.startsWith(path));
  if (!isProtected) return NextResponse.next();

  // Check for the access token cookie
  const token = request.cookies.get('access_token');
  if (!token) {
    const loginUrl = new URL('/login', request.url);
    loginUrl.searchParams.set('returnTo', pathname);
    return NextResponse.redirect(loginUrl);
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/dashboard/:path*', '/settings/:path*', '/profile/:path*'],
};

Login Page

Create a client component that generates a PKCE challenge and redirects the user to UniAuth's authorization endpoint:

// app/login/page.tsx
'use client';

import { useSearchParams } from 'next/navigation';

const UNIAUTH_DOMAIN = process.env.NEXT_PUBLIC_UNIAUTH_DOMAIN!;
const CLIENT_ID = process.env.NEXT_PUBLIC_UNIAUTH_CLIENT_ID!;

// PKCE helpers
function generateRandomString(length: number): string {
  const array = new Uint8Array(length);
  crypto.getRandomValues(array);
  return Array.from(array, b => b.toString(16).padStart(2, '0')).join('');
}

async function generateCodeChallenge(verifier: string): Promise<string> {
  const data = new TextEncoder().encode(verifier);
  const digest = await crypto.subtle.digest('SHA-256', data);
  return btoa(String.fromCharCode(...new Uint8Array(digest)))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}

export default function LoginPage() {
  const searchParams = useSearchParams();
  const returnTo = searchParams.get('returnTo') || '/dashboard';

  const handleLogin = async () => {
    // Generate PKCE code verifier and challenge
    const codeVerifier = generateRandomString(32);
    const codeChallenge = await generateCodeChallenge(codeVerifier);
    const state = generateRandomString(16);

    // Store PKCE verifier and state in sessionStorage
    sessionStorage.setItem('uniauth_code_verifier', codeVerifier);
    sessionStorage.setItem('uniauth_state', state);
    sessionStorage.setItem('uniauth_return_to', returnTo);

    // Redirect to UniAuth authorization endpoint
    const params = new URLSearchParams({
      response_type: 'code',
      client_id: CLIENT_ID,
      redirect_uri: `${window.location.origin}/callback`,
      scope: 'openid profile email',
      state,
      code_challenge: codeChallenge,
      code_challenge_method: 'S256',
    });

    window.location.href = `${UNIAUTH_DOMAIN}/api/oauth/authorize?${params}`;
  };

  return (
    <div className="min-h-screen flex items-center justify-center">
      <div className="max-w-md w-full space-y-8 p-8">
        <div className="text-center">
          <h1 className="text-3xl font-bold">Welcome</h1>
          <p className="mt-2 text-gray-600">Sign in to continue to your dashboard.</p>
        </div>
        <button
          onClick={handleLogin}
          className="w-full py-3 px-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
        >
          Sign in with UniAuth
        </button>
      </div>
    </div>
  );
}

Callback Page

Create a client component that receives the authorization code from UniAuth and sends it to your API route for server-side token exchange:

// app/callback/page.tsx
'use client';

import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';

export default function CallbackPage() {
  const router = useRouter();
  const searchParams = useSearchParams();
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const exchangeCode = async () => {
      const code = searchParams.get('code');
      const state = searchParams.get('state');
      const savedState = sessionStorage.getItem('uniauth_state');
      const codeVerifier = sessionStorage.getItem('uniauth_code_verifier');
      const returnTo = sessionStorage.getItem('uniauth_return_to') || '/dashboard';

      // Verify CSRF state
      if (!code || !state || state !== savedState) {
        setError('Invalid authentication response. Please try again.');
        return;
      }

      if (!codeVerifier) {
        setError('Missing PKCE verifier. Please try logging in again.');
        return;
      }

      try {
        // Exchange code for tokens via our server-side API route
        const response = await fetch('/api/auth/callback', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            code,
            code_verifier: codeVerifier,
            redirect_uri: `${window.location.origin}/callback`,
          }),
        });

        if (!response.ok) {
          const data = await response.json();
          throw new Error(data.error || 'Token exchange failed');
        }

        // Clean up sessionStorage
        sessionStorage.removeItem('uniauth_state');
        sessionStorage.removeItem('uniauth_code_verifier');
        sessionStorage.removeItem('uniauth_return_to');

        // Redirect to the intended destination
        router.replace(returnTo);
      } catch (err) {
        setError(err instanceof Error ? err.message : 'Authentication failed');
      }
    };

    exchangeCode();
  }, [searchParams, router]);

  if (error) {
    return (
      <div className="min-h-screen flex items-center justify-center">
        <div className="text-center">
          <h2 className="text-xl font-semibold text-red-600">Authentication Failed</h2>
          <p className="mt-2 text-gray-600">{error}</p>
          <a href="/login" className="mt-4 inline-block text-blue-600 underline">
            Try again
          </a>
        </div>
      </div>
    );
  }

  return (
    <div className="min-h-screen flex items-center justify-center">
      <p className="text-gray-600">Completing sign-in...</p>
    </div>
  );
}

Server Component: Get User

Create a helper function that reads the access token from cookies and fetches the user profile from UniAuth's UserInfo endpoint. This runs on the server and never exposes the token to the browser:

// lib/auth.ts
import { cookies } from 'next/headers';

const UNIAUTH_DOMAIN = process.env.NEXT_PUBLIC_UNIAUTH_DOMAIN!;

export interface UniAuthUser {
  sub: string;
  name?: string;
  given_name?: string;
  family_name?: string;
  email?: string;
  email_verified?: boolean;
  picture?: string;
  preferred_username?: string;
  locale?: string;
}

export async function getUser(): Promise<UniAuthUser | null> {
  const cookieStore = await cookies();
  const token = cookieStore.get('access_token');
  if (!token) return null;

  try {
    const response = await fetch(`${UNIAUTH_DOMAIN}/api/oauth/userinfo`, {
      headers: { Authorization: `Bearer ${token.value}` },
      cache: 'no-store',
    });

    if (!response.ok) return null;
    return await response.json();
  } catch {
    return null;
  }
}

export async function getAccessToken(): Promise<string | null> {
  const cookieStore = await cookies();
  const token = cookieStore.get('access_token');
  return token?.value || null;
}

Dashboard Page

Create a protected page using the server component + client component pattern. The server component fetches the user and passes it to the client for rendering:

// app/dashboard/page.tsx
import { redirect } from 'next/navigation';
import { getUser } from '@/lib/auth';
import { DashboardClient } from './dashboard-client';

export default async function DashboardPage() {
  const user = await getUser();
  if (!user) redirect('/login?returnTo=/dashboard');

  return <DashboardClient user={user} />;
}
// app/dashboard/dashboard-client.tsx
'use client';

import type { UniAuthUser } from '@/lib/auth';

export function DashboardClient({ user }: { user: UniAuthUser }) {
  return (
    <div className="max-w-4xl mx-auto py-12 px-4">
      <h1 className="text-3xl font-bold">Dashboard</h1>
      <div className="mt-6 p-6 bg-gray-50 rounded-lg">
        <div className="flex items-center gap-4">
          {user.picture && (
            <img
              src={user.picture}
              alt={user.name || ''}
              className="w-12 h-12 rounded-full"
            />
          )}
          <div>
            <h2 className="text-xl font-semibold">{user.name}</h2>
            <p className="text-gray-600">{user.email}</p>
          </div>
        </div>
      </div>
      <div className="mt-6">
        <a
          href="/api/auth/logout"
          className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
        >
          Sign out
        </a>
      </div>
    </div>
  );
}

Logout

Create an API route that clears the auth cookies and redirects to UniAuth's end-session endpoint:

// app/api/auth/logout/route.ts
import { NextRequest, NextResponse } from 'next/server';

const UNIAUTH_DOMAIN = process.env.NEXT_PUBLIC_UNIAUTH_DOMAIN!;

export async function GET(request: NextRequest) {
  const idToken = request.cookies.get('id_token')?.value;

  // Build the redirect URL
  const response = NextResponse.redirect(
    idToken
      ? `${UNIAUTH_DOMAIN}/api/oauth/end-session?${new URLSearchParams({
          id_token_hint: idToken,
          post_logout_redirect_uri: request.nextUrl.origin,
        })}`
      : request.nextUrl.origin
  );

  // Clear all auth cookies
  response.cookies.delete('access_token');
  response.cookies.delete('refresh_token');
  response.cookies.delete('id_token');

  return response;
}

Token Refresh

Access tokens expire after 1 hour. To refresh them without requiring the user to sign in again, create an API route that uses the refresh token:

// app/api/auth/refresh/route.ts
import { NextRequest, NextResponse } from 'next/server';

const UNIAUTH_DOMAIN = process.env.NEXT_PUBLIC_UNIAUTH_DOMAIN!;
const CLIENT_ID = process.env.NEXT_PUBLIC_UNIAUTH_CLIENT_ID!;
const CLIENT_SECRET = process.env.UNIAUTH_CLIENT_SECRET!;

export async function POST(request: NextRequest) {
  const refreshToken = request.cookies.get('refresh_token')?.value;
  if (!refreshToken) {
    return NextResponse.json({ error: 'No refresh token' }, { status: 401 });
  }

  const tokenResponse = await fetch(`${UNIAUTH_DOMAIN}/api/oauth/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
    }),
  });

  if (!tokenResponse.ok) {
    const response = NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
    response.cookies.delete('access_token');
    response.cookies.delete('refresh_token');
    response.cookies.delete('id_token');
    return response;
  }

  const tokens = await tokenResponse.json();
  const response = NextResponse.json({ success: true });

  response.cookies.set('access_token', tokens.access_token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: tokens.expires_in,
    path: '/',
  });

  if (tokens.refresh_token) {
    response.cookies.set('refresh_token', tokens.refresh_token, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      maxAge: 30 * 24 * 60 * 60,
      path: '/',
    });
  }

  return response;
}

You can call this endpoint from your middleware or from a client-side fetch wrapper that detects 401 responses and attempts a refresh before retrying the request.

Complete Example

Here is a summary of every file in a complete Next.js + UniAuth integration:

FilePurpose
.env.localUniAuth domain, client ID, and client secret
lib/auth.tsServer-side helper to read cookies and fetch user profile
middleware.tsRedirects unauthenticated users from protected routes
app/login/page.tsxGenerates PKCE challenge and redirects to UniAuth
app/callback/page.tsxReceives auth code and sends to API route for exchange
app/api/auth/callback/route.tsExchanges code for tokens server-side and sets cookies
app/api/auth/logout/route.tsClears cookies and redirects to UniAuth end-session
app/api/auth/refresh/route.tsRefreshes expired access tokens using the refresh token
app/dashboard/page.tsxServer component that fetches user and renders dashboard
app/dashboard/dashboard-client.tsxClient component that displays the dashboard UI

Security Considerations

  • Tokens are stored in httpOnly cookies. They cannot be accessed by client-side JavaScript, protecting against XSS attacks.
  • PKCE is mandatory. Even though you are using a confidential client (with a client secret), PKCE provides defense-in-depth against authorization code interception.
  • CSRF protection via the state parameter. The login page generates a random state value stored in sessionStorage. The callback page verifies it matches, preventing cross-site request forgery.
  • Client secret stays server-side. The token exchange happens in an API route, so the client secret is never sent to or accessible from the browser.
  • Use SameSite cookies. Setting sameSite: 'lax' prevents the cookies from being sent in cross-site requests while allowing top-level navigation.

Differences from the React SDK

FeatureReact SDKNext.js Pattern
Token storagesessionStorage (browser)httpOnly cookies (server)
Token exchangeClient-side (public client)Server-side (confidential client)
Client secretNot usedUsed server-side
SSR user accessNot available (client-only)Full access in server components
Route protectionProtectedRoute componentEdge middleware + server redirect

Next Steps