Next.js 16 App Router Skill
Patterns for the FTC Metrics Next.js frontend at packages/web/.
Quick Start
bash
cd packages/web bun dev # http://localhost:3000 bun typecheck # Type check bun lint # Lint
Project Structure
code
packages/web/src/
app/ # App Router pages and layouts
layout.tsx # Root layout with Providers
page.tsx # Landing page (Server Component)
dashboard/layout.tsx # Auth-protected layout
analytics/page.tsx # Client Component with Suspense
analytics/team/[teamNumber]/ # Dynamic route
api/auth/[...nextauth]/ # Auth API routes
components/
providers.tsx # Client-side providers wrapper
header.tsx # Navigation header
lib/
auth.ts # NextAuth v5 configuration
api.ts # API client utilities
types/next-auth.d.ts # Session type augmentation
Key Concepts
| Concept | Pattern | Location |
|---|---|---|
| Server Components | Default, no directive | app/page.tsx |
| Client Components | "use client" directive | components/header.tsx |
| Protected Routes | auth() check in layout | app/dashboard/layout.tsx |
| Dynamic Routes | [param] folder naming | app/analytics/team/[teamNumber]/ |
| API Routes | Route handlers | app/api/auth/[...nextauth]/route.ts |
Common Patterns
1. Root Layout with Providers
tsx
// app/layout.tsx
import { Providers } from "@/components/providers";
import "./globals.css";
export const metadata = {
title: "FTC Metrics",
description: "Scouting platform for FIRST Tech Challenge",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className="min-h-screen bg-background antialiased">
<Providers>{children}</Providers>
</body>
</html>
);
}
tsx
// components/providers.tsx
"use client";
import { SessionProvider } from "next-auth/react";
export function Providers({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}
2. Protected Layout with Auth Check
tsx
// app/dashboard/layout.tsx
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { Header } from "@/components/header";
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const session = await auth();
if (!session?.user) redirect("/login");
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
<Header />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">{children}</main>
</div>
);
}
3. Server Component with Auth
tsx
// app/dashboard/page.tsx
import { auth } from "@/lib/auth";
export default async function DashboardPage() {
const session = await auth();
return <h1>Welcome, {session?.user?.name?.split(" ")[0] || "Scout"}</h1>;
}
4. Client Component with useSearchParams (Suspense Required)
tsx
// app/analytics/page.tsx
"use client";
import { Suspense } from "react";
import { useSearchParams, useRouter } from "next/navigation";
function AnalyticsContent() {
const searchParams = useSearchParams();
const router = useRouter();
const eventCode = searchParams.get("event") || "";
const handleEventChange = (event: string) => {
router.push(`/analytics?event=${event}`, { scroll: false });
};
return <div>{/* Content */}</div>;
}
export default function AnalyticsPage() {
return (
<Suspense fallback={<LoadingSpinner />}>
<AnalyticsContent />
</Suspense>
);
}
5. Dynamic Route with Params
tsx
// app/analytics/team/[teamNumber]/page.tsx
"use client";
import { useParams, useSearchParams } from "next/navigation";
import { Suspense } from "react";
function TeamContent() {
const params = useParams();
const searchParams = useSearchParams();
const teamNumber = parseInt(params.teamNumber as string, 10);
const eventCode = searchParams.get("event") || "";
return <h1>Team {teamNumber}</h1>;
}
export default function TeamPage() {
return (
<Suspense fallback={<LoadingSpinner />}>
<TeamContent />
</Suspense>
);
}
6. NextAuth v5 Configuration
tsx
// lib/auth.ts
import NextAuth from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter";
import Google from "next-auth/providers/google";
import { prisma } from "@ftcmetrics/db";
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
],
pages: { signIn: "/login", error: "/login" },
callbacks: {
async session({ session, user }) {
if (session.user) session.user.id = user.id;
return session;
},
},
session: { strategy: "database" },
});
tsx
// app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;
7. Session Type Augmentation
tsx
// types/next-auth.d.ts
import { DefaultSession } from "next-auth";
declare module "next-auth" {
interface Session {
user: { id: string } & DefaultSession["user"];
}
}
8. Client Component with useSession
tsx
// components/header.tsx
"use client";
import { useSession, signOut } from "next-auth/react";
import Link from "next/link";
export function Header() {
const { data: session } = useSession();
return (
<header>
{session?.user ? (
<button onClick={() => signOut({ callbackUrl: "/" })}>Sign out</button>
) : (
<Link href="/login">Sign in</Link>
)}
</header>
);
}
9. Redirect Based on Auth State
tsx
// app/page.tsx
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
export default async function Home() {
const session = await auth();
if (session?.user) redirect("/dashboard");
return <LandingPage />;
}
Anti-Patterns
Using auth() in Client Components
tsx
// Server Component - uses auth()
export default async function Page() {
const session = await auth();
return <Display user={session?.user} />;
}
// Client Component - uses useSession hook
"use client";
import { useSession } from "next-auth/react";
export function UserProfile() {
const { data: session, status } = useSession();
if (status === "loading") return <Skeleton />;
return <Profile user={session?.user} />;
}
useSearchParams Without Suspense
tsx
// WRONG: Will cause hydration errors
"use client";
export default function SearchPage() {
const params = useSearchParams(); // No Suspense wrapper
return <Results query={params.get("q")} />;
}
// CORRECT: Wrap in Suspense
"use client";
function SearchContent() {
const params = useSearchParams();
return <Results query={params.get("q")} />;
}
export default function SearchPage() {
return (
<Suspense fallback={<Loading />}>
<SearchContent />
</Suspense>
);
}
Importing signIn/signOut Wrong
tsx
// Server actions - import from lib/auth
import { signIn, signOut } from "@/lib/auth";
// Client components - import from next-auth/react
"use client";
import { signIn, signOut } from "next-auth/react";
Tailwind Configuration
ts
// tailwind.config.ts
const config = {
content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"],
theme: {
extend: {
colors: {
ftc: {
orange: "#f57e25", // Primary: text-ftc-orange, bg-ftc-orange
blue: "#0066b3", // Secondary: text-ftc-blue, bg-ftc-blue
dark: "#1a1a2e", // Dark: bg-ftc-dark
},
},
},
},
};
Next.js Configuration
ts
// next.config.ts
const nextConfig = {
reactStrictMode: true,
transpilePackages: ["@ftcmetrics/shared"], // Monorepo packages
};
Monorepo Integration
tsx
// Workspace packages
import { prisma } from "@ftcmetrics/db";
import { TeamType } from "@ftcmetrics/shared";
// Local imports with @/ alias
import { auth } from "@/lib/auth";
import { Header } from "@/components/header";
Loading Spinner Pattern
Used throughout the codebase:
tsx
<div className="flex items-center justify-center py-12"> <div className="animate-spin rounded-full h-8 w-8 border-2 border-ftc-orange border-t-transparent" /> </div>