This guide walks through setting up authentication in a Next.js App Router application using ideal-auth. By the end, you will have working login, registration, logout, route protection via middleware, and access to the current user in Server Components.
Installation
-
Install ideal-auth
bun add ideal-auth -
Set the session secret
Add a secret to your
.env.localfile. It must be at least 32 characters..env.localbash IDEAL_AUTH_SECRET="at-least-32-characters-long-secret-here"Generate a strong secret:
bunx ideal-auth secret
Cookie bridge
Next.js exposes a cookies() helper from next/headers. The bridge maps it to the three functions ideal-auth expects.
import { cookies } from 'next/headers';
import type { CookieBridge } from 'ideal-auth';
export function createCookieBridge(): CookieBridge {
return {
async get(name: string) {
const cookieStore = await cookies();
return cookieStore.get(name)?.value;
},
async set(name, value, options) {
const cookieStore = await cookies();
cookieStore.set(name, value, options);
},
async delete(name) {
const cookieStore = await cookies();
cookieStore.delete(name);
},
};
}Auth setup
Create a single auth() factory. Every Server Action or Route Handler calls auth() to get a fresh AuthInstance bound to the current request’s cookies.
import { createAuth, createHash } from 'ideal-auth';
import { createCookieBridge } from './cookies';
import { db } from './db'; // your database client
type User = {
id: string;
email: string;
name: string;
password: string;
};
const hash = createHash({ rounds: 12 });
const auth = createAuth<User>({
secret: process.env.IDEAL_AUTH_SECRET!,
cookie: createCookieBridge(),
hash,
// Look up a user by their ID (used by check/user/id)
async resolveUser(id) {
return db.user.findUnique({ where: { id } });
},
// Look up a user by non-password credentials (used by attempt)
async resolveUserByCredentials(credentials) {
return db.user.findUnique({
where: { email: credentials.email },
});
},
});
export { auth, hash };Login action
'use server';
import { redirect } from 'next/navigation';
import { auth } from '@/lib/auth';
export async function loginAction(_prev: unknown, formData: FormData) {
const email = formData.get('email') as string;
const password = formData.get('password') as string;
const remember = formData.get('remember') === 'on';
if (!email || !password) {
return { error: 'Email and password are required.' };
}
const session = auth();
const success = await session.attempt(
{ email, password },
{ remember },
);
if (!success) {
return { error: 'Invalid email or password.' };
}
redirect('/dashboard');
}Login form
'use client';
import { useActionState } from 'react';
import { loginAction } from '@/app/actions/login';
export default function LoginPage() {
const [state, formAction, pending] = useActionState(loginAction, null);
return (
<form action={formAction}>
{state?.error && <p className="text-red-500">{state.error}</p>}
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
<label htmlFor="password">Password</label>
<input id="password" name="password" type="password" required />
<label>
<input name="remember" type="checkbox" /> Remember me
</label>
<button type="submit" disabled={pending}>
{pending ? 'Signing in...' : 'Sign in'}
</button>
</form>
);
}Registration action
'use server';
import { redirect } from 'next/navigation';
import { auth, hash } from '@/lib/auth';
import { db } from '@/lib/db';
export async function registerAction(_prev: unknown, formData: FormData) {
const email = formData.get('email') as string;
const name = formData.get('name') as string;
const password = formData.get('password') as string;
const passwordConfirmation = formData.get('password_confirmation') as string;
if (!email || !name || !password) {
return { error: 'All fields are required.' };
}
if (password.length < 8) {
return { error: 'Password must be at least 8 characters.' };
}
if (password !== passwordConfirmation) {
return { error: 'Passwords do not match.' };
}
const existing = await db.user.findUnique({ where: { email } });
if (existing) {
return { error: 'An account with this email already exists.' };
}
const user = await db.user.create({
data: {
email,
name,
password: await hash.make(password),
},
});
// Log the user in immediately after registration
const session = auth();
await session.login(user);
redirect('/dashboard');
}Logout action
'use server';
import { redirect } from 'next/navigation';
import { auth } from '@/lib/auth';
export async function logoutAction() {
const session = auth();
await session.logout();
redirect('/login');
}Logout button
'use client';
import { logoutAction } from '@/app/actions/logout';
export function LogoutButton() {
return (
<form action={logoutAction}>
<button type="submit">Sign out</button>
</form>
);
}Middleware (route protection)
Next.js middleware runs on the Edge Runtime before every matching request. Because ideal-auth uses iron-session (which relies on Node.js crypto), you cannot use auth() directly in Edge middleware. Instead, check for the existence of the session cookie and let the server-side code handle full validation.
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const protectedRoutes = ['/dashboard', '/settings', '/profile'];
const authRoutes = ['/login', '/register'];
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const hasSession = request.cookies.has('ideal_session');
// Redirect unauthenticated users away from protected routes
if (protectedRoutes.some((route) => pathname.startsWith(route))) {
if (!hasSession) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('callbackUrl', pathname);
return NextResponse.redirect(loginUrl);
}
}
// Redirect authenticated users away from auth routes
if (authRoutes.some((route) => pathname.startsWith(route))) {
if (hasSession) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*', '/settings/:path*', '/profile/:path*', '/login', '/register'],
};For full server-side route guards (e.g., inside layouts), you can also create a helper:
import { redirect } from 'next/navigation';
import { auth } from '@/lib/auth';
export async function requireAuth() {
const session = auth();
const user = await session.user();
if (!user) {
redirect('/login');
}
return user;
}Getting the current user
In a Server Component
import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';
import { LogoutButton } from '@/components/logout-button';
export default async function DashboardPage() {
const session = auth();
const user = await session.user();
if (!user) {
redirect('/login');
}
return (
<div>
<h1>Welcome, {user.name}</h1>
<p>Email: {user.email}</p>
<LogoutButton />
</div>
);
}In a Layout
import { requireAuth } from '@/lib/auth-guard';
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const user = await requireAuth();
return (
<div>
<nav>
<span>Signed in as {user.email}</span>
</nav>
<main>{children}</main>
</div>
);
}Passing user data to Client Components
If a Client Component needs user data, pass it as props from a Server Component:
import { requireAuth } from '@/lib/auth-guard';
import { DashboardClient } from './dashboard-client';
export default async function DashboardPage() {
const user = await requireAuth();
// Only pass serializable, non-sensitive fields
return <DashboardClient user={{ id: user.id, name: user.name, email: user.email }} />;
}CSRF protection
Next.js Server Actions have built-in CSRF protection. The framework automatically validates the Origin header on all Server Action requests, rejecting cross-origin submissions. No additional configuration is needed.
For API Route Handlers (app/api/*/route.ts), if you accept form submissions or state-changing requests, you should validate the Origin header manually:
import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
const headerStore = await headers();
const origin = headerStore.get('origin');
const host = headerStore.get('host');
if (!origin || new URL(origin).host !== host) {
return NextResponse.json({ error: 'Invalid origin' }, { status: 403 });
}
// ... handle the request
}Security notes
- Session secret: Never commit
IDEAL_AUTH_SECRETto version control. Use environment variables in your deployment platform. - HTTPS: Set
NODE_ENV=productionin production so session cookies are automatically markedSecure. - Cookie scope: The default
SameSite=LaxandHttpOnly=truesettings protect against CSRF and XSS cookie theft. Do not overridehttpOnly. - Password hashing:
createHashuses bcrypt with SHA-256 pre-hashing for passwords over 72 bytes. The default 12 rounds is suitable for production. - Edge Runtime: The
auth()function requires Node.js runtime. Useexport const runtime = 'nodejs'in any Route Handler that callsauth(). The middleware pattern above works around this limitation.