Protecting Pages
Authentication requirements for pages and API routes
Pages and API routes require authentication checks to prevent unauthorized access. StarterApp provides patterns for protecting server components, client components, layouts, and API endpoints.
Server-First Protection
Authentication checks belong in server components, layouts, and API routes. Client-side checks optimize UI only. The server is the source of truth for authorization.
Server Component Protection
Server components use requireUser()
or getCurrentSession()
from packages/app-shell/src/lib/auth/server.ts
:
Required Authentication
Use requireUser()
to redirect unauthenticated users to /sign-in
:
import { requireUser } from "~/lib/auth/server";
export default async function SettingsPage() {
const user = await requireUser();
return (
<div>
<h1>Settings for {user.name}</h1>
<p>Email: {user.email}</p>
</div>
);
}
Optional Authentication
Use getCurrentSession()
for pages with different views for authenticated and public users:
import { getCurrentSession } from "~/lib/auth/server";
export default async function HomePage() {
const session = await getCurrentSession();
if (session) {
return <DashboardView user={session.user} />;
}
return <LandingPageView />;
}
Cache Configuration Required
Protected server components must export cache configuration to prevent stale session data:
export const revalidate = 0;
export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
Layout Protection
Protect entire route trees by placing authentication checks in layouts. The dashboard layout at apps/dashboard/app/(dashboard)/layout.tsx
demonstrates this pattern:
import { getCurrentSession } from "~/lib/auth/server";
import { redirect } from "next/navigation";
export const revalidate = 0;
export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
export default async function DashboardRootLayout({
children,
}: PropsWithChildren) {
const session = await getCurrentSession();
if (!session) {
redirect("/sign-in");
}
return (
<div className="flex min-h-screen bg-[#f7f8fa] text-slate-900">
<Sidebar
user={{
id: session.user.id,
name: session.user.name,
email: session.user.email,
image: session.user.image,
}}
/>
<div className="flex flex-1 flex-col">
<main className="flex-1 overflow-y-auto px-8 py-12">
<div className="mx-auto w-full max-w-5xl space-y-8">
{children}
</div>
</main>
</div>
</div>
);
}
This layout protects all routes under (dashboard)/*
:
/dashboard
- Dashboard home/app
- Application view/billing
- Billing management/resources
- User resources/support
- Support tickets
Layout Performance
Authentication checks in layouts run once per navigation, not on every page render. This minimizes database queries and improves performance.
Client Component Protection
Client components use hooks from @workspace/auth/client
:
Basic Auth Check
"use client";
import { useSession } from "@workspace/auth/client";
export function ProtectedContent() {
const { data: session, isPending } = useSession();
if (isPending) {
return <div className="animate-pulse">Loading...</div>;
}
if (!session?.user) {
return (
<div>
<p>Please sign in to view this content</p>
<a href="/sign-in">Sign In</a>
</div>
);
}
return (
<div>
<h2>Protected Content</h2>
<p>Welcome, {session.user.name}</p>
</div>
);
}
Convex Real-Time Auth
For real-time authentication state, use Convex queries:
"use client";
import { useQuery } from "convex/react";
import { api } from "@convex/_generated/api";
export function LiveProtectedContent() {
const user = useQuery(api.auth.getCurrentUser);
if (user === undefined) {
return <div>Loading...</div>;
}
if (user === null) {
return <div>Please sign in</div>;
}
return (
<div>
<h2>Protected Content</h2>
<p>User ID: {user.userId}</p>
<p>Email: {user.email}</p>
</div>
);
}
Server-First Protection
Client-side auth checks are for UI optimization only. Always enforce authentication on the server (layouts, pages, API routes, Convex functions).
API Route Protection
API routes require authentication checks combined with CSRF validation. The template at llms/templates/auth-api-route-template.ts
provides the full pattern:
Protected GET Endpoint
import { auth } from "@workspace/auth/server";
import { secureErrorJson, secureUserJson } from "@workspace/security";
import { headers } from "next/headers";
import type { NextRequest } from "next/server";
export const runtime = "nodejs";
export const revalidate = 0;
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return secureErrorJson(
{ message: "Authentication required", code: "Unauthorized" },
{ status: 401, userScoped: true }
);
}
// Fetch user data
const data = await fetchUserData(session.user.id);
return secureUserJson({ ok: true, data });
} catch (error) {
return secureErrorJson(
{ message: "Internal server error", code: "Internal" },
{ status: 500, userScoped: true }
);
}
}
Protected POST Endpoint with CSRF
import { auth } from "@workspace/auth/server";
import { secureErrorJson, secureUserJson } from "@workspace/security";
import { headers } from "next/headers";
import type { NextRequest } from "next/server";
import { z } from "zod";
import { assertOrigin, assertFetchMetadata } from "~/lib/security/csrf";
export const runtime = "nodejs";
export const revalidate = 0;
export const dynamic = "force-dynamic";
const BodySchema = z.object({
title: z.string().trim().min(1).max(200),
content: z.string().trim().min(10).max(5000),
});
export async function POST(req: NextRequest) {
try {
// CSRF protection
assertOrigin(req);
assertFetchMetadata(req);
// Authentication check
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return secureErrorJson(
{ message: "Authentication required", code: "Unauthorized" },
{ status: 401, userScoped: true }
);
}
// Validation
const body = BodySchema.parse(await req.json());
// Business logic
const record = await createResource({
...body,
userId: session.user.id,
});
return secureUserJson({ ok: true, record }, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return secureErrorJson(
{
message: "Validation failed",
code: "ValidationError",
issues: error.issues.map((i) => i.message),
},
{ status: 400, userScoped: true }
);
}
return secureErrorJson(
{ message: "Internal server error", code: "Internal" },
{ status: 500, userScoped: true }
);
}
}
Convex Function Protection
Convex queries and mutations use identity helpers from convex/lib/identity.ts
:
Protected Query
import { query } from "./_generated/server";
import { v } from "convex/values";
import { getUserIdOrThrow } from "./lib/identity";
export const getPrivateData = query({
args: { recordId: v.id("records") },
handler: async (ctx, { recordId }) => {
const userId = await getUserIdOrThrow(ctx);
const record = await ctx.db.get(recordId);
if (!record) {
throw new Error("Not found");
}
if (record.userId !== userId) {
throw new Error("Access denied");
}
return record;
},
});
Protected Mutation
import { mutation } from "./_generated/server";
import { v } from "convex/values";
import { getUserIdOrThrow } from "./lib/identity";
export const updateSettings = mutation({
args: {
theme: v.union(v.literal("light"), v.literal("dark")),
notifications: v.boolean(),
},
handler: async (ctx, input) => {
const userId = await getUserIdOrThrow(ctx);
await ctx.db.patch(userId, {
settings: input,
updatedAt: Date.now(),
});
return { ok: true };
},
});
Ownership Validation
Use assertOwnerByUserId
for resource ownership checks with optional existence concealment:
import { query } from "./_generated/server";
import { v } from "convex/values";
import { getUserIdOrThrow, assertOwnerByUserId } from "./lib/identity";
export const getPrivateNote = query({
args: { noteId: v.id("notes") },
handler: async (ctx, { noteId }) => {
const userId = await getUserIdOrThrow(ctx);
const note = await ctx.db.get(noteId);
// Throws "Not Found" instead of "Forbidden" to hide existence
assertOwnerByUserId(note, userId, { concealExistence: true });
return note;
},
});
Identity Helpers
The getUserIdOrThrow
function automatically throws an AppError('UNAUTHENTICATED')
for unauthenticated requests, which Convex converts to a 401 response.
Middleware Considerations
The codebase uses middleware for security headers, not authentication checks:
// apps/dashboard/middleware.ts
import { hybridCsp } from "@workspace/security/middleware";
export default hybridCsp();
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};
Authentication in Middleware
Authentication checks belong in server components, layouts, and API routes, not middleware. This keeps logic centralized, type-safe, and testable.
Required Exports
Protected routes must export cache configuration:
export const revalidate = 0;
export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
Why these exports are required:
revalidate = 0
: Disables ISR (Incremental Static Regeneration)dynamic = 'force-dynamic'
: Forces dynamic rendering on every requestfetchCache = 'force-no-store'
: Prevents fetch response caching
Without these exports, Next.js may cache authenticated responses and serve them to unauthenticated users.
Template Files
The codebase provides complete implementation templates:
Prop
Type
These templates demonstrate production-ready patterns including error handling, type safety, security headers, and proper status codes.