Protecting pages and actions on the front end
The front end's job in authorisation is experience, not security. The API decides what is allowed; the browser decides what to show. Getting that relationship right is the whole lesson.
What the front end is for
Hiding an admin link from a customer is not a security measure — module 1 established that anyone can call the endpoint directly. It is a courtesy: it stops people finding buttons that will only tell them no.
Every check you write here is duplicated on the server. If it is not, it is not a check.
That said, front-end protection is worth doing properly, because an application that lets you click through to a page and then shows an error feels broken.
Reading the current user
The session lives in an httpOnly cookie, so the browser cannot read it. Ask the server:
// apps/web/src/lib/auth.ts
import { cookies } from "next/headers";
import type { AuthUser } from "@kirana/shared";
export async function getCurrentUser(): Promise<AuthUser | null> {
const cookieStore = await cookies();
const response = await fetch(`${process.env.API_URL}/auth/me`, {
headers: { cookie: cookieStore.toString() },
cache: "no-store",
});
if (!response.ok) return null;
const { user } = await response.json();
return user;
}
Three things matter here.
Forward the cookie manually. A server component's fetch is a server-to-server
call and carries no browser cookies of its own — you have to pass them.
cache: "no-store". Caching the current user would show one customer
another's account. This is the most important no-store in the application.
Return null rather than throwing. Not being logged in is a normal state,
not an error.
Note that cookies() makes the route dynamic, from module 3. That is correct
for anything user-specific, and a reason to keep getCurrentUser out of pages
that should stay static.
Protecting a page
// apps/web/src/app/(shop)/orders/page.tsx
import { redirect } from "next/navigation";
import { getCurrentUser } from "@/lib/auth";
export default async function OrdersPage() {
const user = await getCurrentUser();
if (!user) {
redirect("/login?next=/orders");
}
const orders = await getOrders();
return <OrderList orders={orders} />;
}
Redirect on the server, before anything renders. A client-side redirect means the page flashes first, and on a slow connection that flash is long enough to read.
?next=/orders so login can return them where they were going. Being
bounced to the home page after logging in is a small, constant irritation.
Admin pages check the role too:
const user = await getCurrentUser();
if (!user) redirect("/login?next=/admin");
if (user.role !== "ADMIN") notFound();
notFound() rather than a "forbidden" page, for the same reason as the API:
a 403 confirms the page exists.
Middleware for whole sections
Checking in every page is repetitive and forgettable. Middleware runs before every matching request:
// apps/web/src/middleware.ts
import { NextResponse, type NextRequest } from "next/server";
const PROTECTED = ["/orders", "/account", "/checkout", "/admin"];
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
if (!PROTECTED.some((path) => pathname.startsWith(path))) {
return NextResponse.next();
}
const token = request.cookies.get("access_token");
if (!token) {
const url = new URL("/login", request.url);
url.searchParams.set("next", pathname);
return NextResponse.redirect(url);
}
return NextResponse.next();
}
export const config = {
matcher: ["/orders/:path*", "/account/:path*", "/checkout/:path*", "/admin/:path*"],
};
Middleware only checks that a cookie exists. It does not verify the signature, has not confirmed the user is still active, and knows nothing about roles. That is deliberate — middleware runs on every matching request and should be fast.
So middleware is a redirect optimisation, not a security boundary. Keep the real check in the page and, above all, in the API. A forged cookie gets past middleware and fails at the API, which is the correct outcome.
The matcher limits which paths run it. Without it, middleware runs for every
request including static assets.
Conditional interface
export async function Header() {
const user = await getCurrentUser();
return (
<header className="flex items-center justify-between p-4">
<Link href="/">Kirana Store</Link>
<nav className="flex items-center gap-4">
<Link href="/cart">Cart</Link>
{user ? (
<>
<Link href="/orders">My orders</Link>
{user.role === "ADMIN" && <Link href="/admin">Admin</Link>}
<LogoutButton />
</>
) : (
<Link href="/login">Sign in</Link>
)}
</nav>
</header>
);
}
A server component, so the correct version arrives in the HTML with no flash of the wrong state.
The admin link is hidden, not protected. Anyone can type /admin, and the
page check and the API guard are what stop them.
Login
"use client";
export function LoginForm({ next }: { next: string }) {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (submitting) return;
setSubmitting(true);
setError(null);
const data = new FormData(event.currentTarget);
try {
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
email: data.get("email"),
password: data.get("password"),
}),
});
if (!response.ok) {
const body = await response.json().catch(() => null);
setError(body?.message ?? "Could not sign in. Please try again.");
return;
}
router.replace(next);
router.refresh();
} catch {
setError("Could not reach the server. Check your connection.");
} finally {
setSubmitting(false);
}
}
// ...
}
Four things worth pulling out.
credentials: "include" so the browser accepts and sends cookies. Without
it, login appears to succeed and the session never exists — a genuinely
confusing bug.
router.replace, not push. Back should not return to the login form after
signing in.
router.refresh() re-runs the server components, so the header picks up the
new user. Without it the page still shows "Sign in" until a full reload.
A catch for the network failing, separate from a rejected login. "Could
not reach the server" and "wrong password" are different problems.
Never store anything from the response yourself. The cookie is the session.
Handling expiry gracefully
An access token expires after 15 minutes, so a request can fail while somebody is using the site.
export async function apiFetch(path: string, init: RequestInit = {}) {
const request = () =>
fetch(`/api${path}`, { ...init, credentials: "include" });
let response = await request();
if (response.status === 401) {
const refreshed = await fetch("/api/auth/refresh", {
method: "POST",
credentials: "include",
});
if (refreshed.ok) {
response = await request();
} else {
window.location.href = "/login?next=" + encodeURIComponent(location.pathname);
throw new Error("Session expired");
}
}
return response;
}
One retry after a refresh, and a redirect if that fails. Retry once only — a loop where refresh keeps failing and the request keeps retrying will hammer your API.
In production this wants deduplication, so ten simultaneous 401s trigger one refresh rather than ten.
Optimistic interface, honestly
Disabling an Add to Cart button for signed-out users is worse than letting them click it and then prompting to sign in — the second keeps the sale.
Prefer prompting over hiding for anything a signed-out user might want to do. Hide only what would be meaningless to them, such as the admin area.
Check your work
What front-end protection is for: experience. The API is the security boundary.
Why forward cookies manually in a server component: the fetch is server-to-server and carries no browser cookies.
Why cache: "no-store" on the current user: a cached response would show
one customer another's account.
Why redirect on the server: a client redirect flashes the page first.
Why notFound() rather than a forbidden page for admin routes: a 403
confirms the page exists.
Why middleware is not a security boundary: it only checks a cookie exists, without verifying it.
What credentials: "include" does: allows the browser to accept and send
cookies. Without it the session never exists.
Why router.refresh() after login: server components re-run, so the header
reflects the new user.
Why retry a refresh only once: otherwise a persistent failure becomes a loop.
Practice
- Write
getCurrentUserand render the user's name in the header. - Remove the cookie forwarding and watch it always return null.
- Remove
cache: "no-store", sign in as two users in two browsers, and see the wrong name appear. - Protect
/orderswith a server-side redirect including?next=. - Add the middleware. Confirm it redirects before the page renders.
- Forge an
access_tokencookie with a random value. Confirm middleware lets you through and the API rejects you. - Build the login form. Omit
credentials: "include"and watch the session fail to persist. - Remove
router.refresh()and watch the header stay stale. - Set the access token to 30 seconds. Wait, act, and confirm the refresh-retry works.
- Hide the admin link from a customer, then navigate to
/admindirectly. Confirm both the page and the API refuse.
That is module eight. You can register and authenticate users, store credentials safely, protect routes by default, check ownership properly, and build a front end that reflects it without pretending to enforce it.
Next module: the seam between the two halves.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship