StarterApp Docs
Authentication

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 request
  • fetchCache = '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.