Overview
An authenticated dashboard combines three pieces that are almost always taught separately but only really make sense together: a Server Action that runs real logic (checking credentials) in response to a form submit with zero client-side JavaScript required, an httpOnly cookie that carries proof of that login on every later request without ever being readable by the browser's own JavaScript, and middleware that inspects that cookie before a protected route is even allowed to render.
By the end of this tutorial you will have a `/login` page backed by a Server Action that validates credentials and sets a session cookie, a `middleware.ts` at the project root that checks for that cookie on every request to `/dashboard` and redirects anyone without one back to `/login`, and a protected `/dashboard` page that reads the session to greet the logged-in user and offers a way to log out.
- A `lib/auth.ts` module verifying credentials and issuing an opaque session token.
- A `login` Server Action (`'use server'`) that validates the submitted form and sets an httpOnly session cookie via `cookies()`.
- A `<form action={login}>` login page that works with zero client-side JavaScript.
- A `middleware.ts` that runs before every request to `/dashboard/*`, redirecting anyone without a valid session cookie.
- A protected `/dashboard` page that reads the session cookie to personalize its content.
- A `logout` Server Action that clears the session cookie and redirects back to `/login`.
Prerequisites
- Server vs Client Components — knowing which one you are in decides where a `<form action={...}>` and hooks like `useState` are each allowed.
- The `app/` directory convention and dynamic vs static routes, covered in this course's first practice build.
- Basic HTTP concepts — what a cookie is, and the difference between a `GET` and a `POST` request.
- Server Actions and middleware are new here and introduced from scratch — you do not need to know either one already.
- Solid core JavaScript/TypeScript — plain functions, `FormData`, and optional chaining.
Project Structure
`middleware.ts` lives at the project root, next to `package.json` — not inside `app/` — because it needs to intercept a request before Next.js even decides which route it belongs to. `lib/auth.ts` isolates the "what counts as a valid login" logic so both the login action and the dashboard page (and middleware, indirectly, through the cookie it sets) agree on the exact same rules.
middleware.ts // Root-level — runs before requests to /dashboard/* reach any routeapp/ login/ page.tsx // The login form; a Server Component, no 'use client' needed actions.ts // 'use server' — the login and logout Server Actions dashboard/ page.tsx // Protected page; only reachable once middleware lets a request throughlib/ auth.ts // Credential checking + session token creation/parsingStep 1: Model Users and Session Tokens
The password check below is a plain-text comparison purely because this is a teaching stand-in for a real user table — a production app must hash passwords with something like bcrypt and never store or compare them as plain text, exactly the same rule this site's PHP course covers in its own Login System project. The session token itself is intentionally opaque rather than the username alone, so a visitor cannot simply guess `session_admin` and be logged in as someone else; a real app would use a signed JWT (with a library like `jose`) or a random id looked up in a sessions table instead of this simplified stand-in.
// lib/auth.tsconst USERS = [ { username: 'admin', password: 'letmein123', name: 'Alex Admin' },];
export function verifyCredentials(username: string, password: string) { // Plain-text comparison only because this is a teaching stand-in — a real // app must hash passwords with bcrypt/argon2 and compare with a // constant-time verify function, never like this. const user = USERS.find((u) => u.username === username && u.password === password); return user ? { username: user.username, name: user.name } : null;}
// A minimal opaque "session token" — in production this would be a signed// JWT or a random id looked up in a sessions table, not a value an attacker// could construct just by knowing a valid username.export function createSessionToken(username: string) { return 'session_' + username + '_' + Date.now();}
export function readSessionUsername(token: string | undefined) { if (!token || !token.startsWith('session_')) return null; const parts = token.split('_'); return parts[1] ?? null;}Step 2: Write the Login Server Action
The `'use server'` directive at the top of the file marks every export in it as a Server Action — callable directly from a `<form action={...}>` in a Server or Client Component, but the function body always executes on the server and is never included in the JavaScript bundle sent to the browser. `cookies()` from `next/headers` is only writable inside a Server Action or Route Handler, never during a plain component render, because writing a cookie is a deliberate one-time mutation, while a render can happen many times or be cached.
// app/login/actions.ts'use server';
import { cookies } from 'next/headers';import { redirect } from 'next/navigation';import { verifyCredentials, createSessionToken } from '@/lib/auth';
// Note: Next.js 15 made cookies() an async API (await cookies()). This// tutorial shows the synchronous form most course material still uses —// check your installed Next.js version if the exact signature differs.export async function login(formData: FormData) { const username = String(formData.get('username') ?? ''); const password = String(formData.get('password') ?? '');
const user = verifyCredentials(username, password);
if (!user) { // A Server Action invoked from a plain <form action={...}> cannot easily // return a value the way useFormState/useActionState can — redirecting // back to the login page with an error flag is the simplest way to // surface failure within this tutorial's scope. redirect('/login?error=1'); }
const token = createSessionToken(user.username);
cookies().set('session', token, { httpOnly: true, // stops client-side JavaScript (and therefore most XSS payloads) from ever reading this cookie secure: process.env.NODE_ENV === 'production', // only sent over HTTPS outside of local dev sameSite: 'lax', // blocks the cookie being attached to most cross-site requests, mitigating CSRF path: '/', maxAge: 60 * 60 * 24, // 1 day, expressed in seconds });
redirect('/dashboard');}
export async function logout() { cookies().delete('session'); redirect('/login');}Step 3: Build the Login Form
This page has no `'use client'` directive at all — it stays a Server Component. Passing the imported `login` function directly as a `<form>`'s `action` prop works with zero client-side JavaScript or `onSubmit` handler: the browser performs a normal form POST, and Next.js routes that submission straight into the Server Action on the server.
// app/login/page.tsximport { login } from './actions';
export default function LoginPage({ searchParams,}: { searchParams: { error?: string };}) { return ( <div className="login-page"> <h1>Log In</h1>
{searchParams.error && ( <p className="error">Invalid username or password.</p> )}
<form action={login}> <label htmlFor="username">Username</label> <input id="username" name="username" type="text" required />
<label htmlFor="password">Password</label> <input id="password" name="password" type="password" required />
<button type="submit">Log In</button> </form> </div> );}Step 4: Protect Routes with Middleware
Middleware runs before a matched request reaches any route, layout, or page — the earliest point Next.js gives you to inspect or redirect a request. That timing is exactly why it belongs here: rejecting an unauthenticated request in middleware means `/dashboard`'s own code never even starts running, instead of rendering the protected page first and only then discovering, too late, that it should not have.
// middleware.ts (project root, next to package.json — NOT inside app/)import { NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) { const session = request.cookies.get('session');
if (!session) { const loginUrl = new URL('/login', request.url); // Remembers where the visitor was headed, so a future enhancement to // login could redirect back to it instead of always landing on the // dashboard's default view. loginUrl.searchParams.set('from', request.nextUrl.pathname); return NextResponse.redirect(loginUrl); }
return NextResponse.next(); // cookie present — let the request continue through to /dashboard as normal}
// Limits middleware to routes that actually need the check. Running it on// every request — including static assets and unrelated pages — would waste// work checking a cookie that was never required for those routes anyway.export const config = { matcher: ['/dashboard/:path*'],};Middleware executes in a restricted runtime, not full Node.js — no direct database driver connections, no arbitrary `fs` access. It is meant for fast, lightweight checks like this cookie inspection, not for the actual credential verification itself, which is exactly why that heavier logic lives in the `login` Server Action from Step 2 instead of here.
Step 5: Build the Protected Dashboard and Log Out
Reading the cookie again here, on top of the check middleware already performed, is intentional rather than redundant: middleware only confirms *a* session cookie exists, not which user it belongs to. The page still needs to read and decode it to know whose name to show. The logout button reuses the exact same `<form action={...}>` pattern from Step 3, this time pointed at the `logout` action.
// app/dashboard/page.tsximport { cookies } from 'next/headers';import { readSessionUsername } from '@/lib/auth';import { logout } from '../login/actions';
export default function DashboardPage() { const token = cookies().get('session')?.value; const username = readSessionUsername(token);
return ( <div className="dashboard"> <h1>Welcome back, {username}</h1> <p>This page is only reachable because middleware.ts let the request through.</p>
<form action={logout}> <button type="submit">Log Out</button> </form> </div> );}Complete Code
Here is the complete project — every file, fully assembled from the steps above, in the order a request actually flows through them.
const USERS = [ { username: 'admin', password: 'letmein123', name: 'Alex Admin' },];
export function verifyCredentials(username: string, password: string) { const user = USERS.find((u) => u.username === username && u.password === password); return user ? { username: user.username, name: user.name } : null;}
export function createSessionToken(username: string) { return 'session_' + username + '_' + Date.now();}
export function readSessionUsername(token: string | undefined) { if (!token || !token.startsWith('session_')) return null; const parts = token.split('_'); return parts[1] ?? null;}'use server';
import { cookies } from 'next/headers';import { redirect } from 'next/navigation';import { verifyCredentials, createSessionToken } from '@/lib/auth';
export async function login(formData: FormData) { const username = String(formData.get('username') ?? ''); const password = String(formData.get('password') ?? ''); const user = verifyCredentials(username, password);
if (!user) { redirect('/login?error=1'); }
const token = createSessionToken(user.username);
cookies().set('session', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', maxAge: 60 * 60 * 24, });
redirect('/dashboard');}
export async function logout() { cookies().delete('session'); redirect('/login');}import { login } from './actions';
export default function LoginPage({ searchParams,}: { searchParams: { error?: string };}) { return ( <div className="login-page"> <h1>Log In</h1> {searchParams.error && <p className="error">Invalid username or password.</p>} <form action={login}> <label htmlFor="username">Username</label> <input id="username" name="username" type="text" required /> <label htmlFor="password">Password</label> <input id="password" name="password" type="password" required /> <button type="submit">Log In</button> </form> </div> );}import { NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) { const session = request.cookies.get('session');
if (!session) { const loginUrl = new URL('/login', request.url); loginUrl.searchParams.set('from', request.nextUrl.pathname); return NextResponse.redirect(loginUrl); }
return NextResponse.next();}
export const config = { matcher: ['/dashboard/:path*'],};import { cookies } from 'next/headers';import { readSessionUsername } from '@/lib/auth';import { logout } from '../login/actions';
export default function DashboardPage() { const token = cookies().get('session')?.value; const username = readSessionUsername(token);
return ( <div className="dashboard"> <h1>Welcome back, {username}</h1> <p>This page is only reachable because middleware.ts let the request through.</p> <form action={logout}> <button type="submit">Log Out</button> </form> </div> );}Sample Run
Click Run to see what this code prints.
Extend This Project
- Replace the plain-text `USERS` array with a real database table and hash every password with `bcrypt` before storing it.
- Swap the opaque session token for a signed JWT (using a library like `jose`) with an expiry claim middleware can verify without a database round trip.
- Add role-based access by storing a `role` in the session and branching middleware's redirect logic per protected route.
- Rate-limit failed login attempts per IP or username to slow down brute-force guessing.
- Add a "remember me" checkbox that sets a longer `maxAge` only when checked, instead of always using a fixed 1-day expiry.
Summary
You built a login flow where a Server Action validates credentials and sets an httpOnly cookie with zero client-side JavaScript required, and middleware inspects that same cookie before a protected route is ever allowed to render. That split — Server Actions for the actual mutation, middleware for the gatekeeping check that runs earliest — is the pattern behind authentication in almost every real Next.js application, whatever the underlying user store and token format end up being.