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)
Installation
npm install @uniauth/jsThe @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-secretNEXT_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.localAPI 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:
| File | Purpose |
|---|---|
.env.local | UniAuth domain, client ID, and client secret |
lib/auth.ts | Server-side helper to read cookies and fetch user profile |
middleware.ts | Redirects unauthenticated users from protected routes |
app/login/page.tsx | Generates PKCE challenge and redirects to UniAuth |
app/callback/page.tsx | Receives auth code and sends to API route for exchange |
app/api/auth/callback/route.ts | Exchanges code for tokens server-side and sets cookies |
app/api/auth/logout/route.ts | Clears cookies and redirects to UniAuth end-session |
app/api/auth/refresh/route.ts | Refreshes expired access tokens using the refresh token |
app/dashboard/page.tsx | Server component that fetches user and renders dashboard |
app/dashboard/dashboard-client.tsx | Client 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
| Feature | React SDK | Next.js Pattern |
|---|---|---|
| Token storage | sessionStorage (browser) | httpOnly cookies (server) |
| Token exchange | Client-side (public client) | Server-side (confidential client) |
| Client secret | Not used | Used server-side |
| SSR user access | Not available (client-only) | Full access in server components |
| Route protection | ProtectedRoute component | Edge middleware + server redirect |
Next Steps
- Learn about OAuth scopes and pairwise subject identifiers
- Add multi-factor authentication for your users
- Explore the API reference for additional endpoints
- Set up webhooks to sync user events to your backend
- Review the password policy for account security details