--- name: nextjs-patterns description: Next.js 15 App Router patterns - use for frontend pages, API routes, server components, client components, and middleware --- # Next.js 15 App Router Patterns ## File Structure ``` app/ ├── layout.tsx # Root layout (required) ├── page.tsx # Home page (/) ├── loading.tsx # Loading UI ├── error.tsx # Error boundary ├── not-found.tsx # 404 page ├── globals.css # Global styles ├── environments/ │ ├── page.tsx # /environments │ ├── [id]/ │ │ ├── page.tsx # /environments/[id] │ │ └── loading.tsx # Loading for this route │ └── new/ │ └── page.tsx # /environments/new ├── api/ │ └── environments/ │ ├── route.ts # GET/POST /api/environments │ └── [id]/ │ └── route.ts # GET/PUT/DELETE /api/environments/[id] └── (auth)/ # Route group (no URL impact) ├── login/ │ └── page.tsx └── layout.tsx # Shared auth layout ``` ## Server Components (Default) ```tsx // app/environments/page.tsx // Server Component - can use async/await directly import { getEnvironments } from '@/lib/api' export default async function EnvironmentsPage() { const environments = await getEnvironments() return (

Environments

) } // With search params export default async function EnvironmentsPage({ searchParams, }: { searchParams: Promise<{ status?: string; page?: string }> }) { const params = await searchParams const environments = await getEnvironments({ status: params.status, page: parseInt(params.page || '1'), }) return } ``` ## Client Components ```tsx // components/EnvironmentActions.tsx 'use client' import { useState } from 'react' import { useRouter } from 'next/navigation' export function EnvironmentActions({ id }: { id: string }) { const [isLoading, setIsLoading] = useState(false) const router = useRouter() async function handleDelete() { setIsLoading(true) try { await fetch(`/api/environments/${id}`, { method: 'DELETE' }) router.refresh() // Refresh server components } finally { setIsLoading(false) } } return ( ) } ``` ## API Route Handlers ```tsx // app/api/environments/route.ts import { NextRequest, NextResponse } from 'next/server' import { z } from 'zod' const CreateEnvironmentSchema = z.object({ name: z.string().min(1).max(100), description: z.string().optional(), }) // GET /api/environments export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams const status = searchParams.get('status') const environments = await prisma.environment.findMany({ where: status ? { status } : undefined, orderBy: { createdAt: 'desc' }, }) return NextResponse.json(environments) } // POST /api/environments export async function POST(request: NextRequest) { try { const body = await request.json() const data = CreateEnvironmentSchema.parse(body) const environment = await prisma.environment.create({ data: { name: data.name, description: data.description, status: 'PENDING', }, }) return NextResponse.json(environment, { status: 201 }) } catch (error) { if (error instanceof z.ZodError) { return NextResponse.json( { error: 'Validation failed', details: error.errors }, { status: 400 } ) } throw error } } // app/api/environments/[id]/route.ts export async function GET( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { const { id } = await params const environment = await prisma.environment.findUnique({ where: { id }, }) if (!environment) { return NextResponse.json( { error: 'Environment not found' }, { status: 404 } ) } return NextResponse.json(environment) } ``` ## Middleware ```tsx // middleware.ts import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' import { getToken } from 'next-auth/jwt' export async function middleware(request: NextRequest) { const token = await getToken({ req: request }) const isAuthPage = request.nextUrl.pathname.startsWith('/login') // Redirect authenticated users away from auth pages if (isAuthPage && token) { return NextResponse.redirect(new URL('/dashboard', request.url)) } // Protect dashboard routes if (request.nextUrl.pathname.startsWith('/dashboard') && !token) { return NextResponse.redirect(new URL('/login', request.url)) } return NextResponse.next() } export const config = { matcher: ['/dashboard/:path*', '/login'], } ``` ## NextAuth.js Integration ```tsx // lib/auth.ts import NextAuth from 'next-auth' import GoogleProvider from 'next-auth/providers/google' import { PrismaAdapter } from '@auth/prisma-adapter' import { prisma } from '@/lib/prisma' export const { handlers, auth, signIn, signOut } = NextAuth({ adapter: PrismaAdapter(prisma), providers: [ GoogleProvider({ clientId: process.env.GOOGLE_CLIENT_ID!, clientSecret: process.env.GOOGLE_CLIENT_SECRET!, }), ], callbacks: { session({ session, user }) { session.user.id = user.id return session }, }, }) // app/api/auth/[...nextauth]/route.ts import { handlers } from '@/lib/auth' export const { GET, POST } = handlers ``` ## Server Actions ```tsx // app/environments/actions.ts 'use server' import { revalidatePath } from 'next/cache' import { redirect } from 'next/navigation' import { z } from 'zod' const CreateSchema = z.object({ name: z.string().min(1), }) export async function createEnvironment(formData: FormData) { const data = CreateSchema.parse({ name: formData.get('name'), }) await prisma.environment.create({ data: { name: data.name, status: 'PENDING' }, }) revalidatePath('/environments') redirect('/environments') } // Usage in component import { createEnvironment } from './actions' export function CreateForm() { return (
) } ``` ## Loading & Error States ```tsx // app/environments/loading.tsx export default function Loading() { return (
{[...Array(5)].map((_, i) => (
))}
) } // app/environments/error.tsx 'use client' export default function Error({ error, reset, }: { error: Error & { digest?: string } reset: () => void }) { return (

Something went wrong!

{error.message}

) } ``` ## Data Fetching Patterns ```tsx // lib/api.ts const API_URL = process.env.FACADE_URL || 'http://localhost:1337' export async function getEnvironments() { const res = await fetch(`${API_URL}/api/v1/environments`, { next: { revalidate: 60 }, // ISR: revalidate every 60 seconds }) if (!res.ok) { throw new Error('Failed to fetch environments') } return res.json() } export async function getEnvironment(id: string) { const res = await fetch(`${API_URL}/api/v1/environments/${id}`, { cache: 'no-store', // Always fresh }) if (!res.ok) { if (res.status === 404) return null throw new Error('Failed to fetch environment') } return res.json() } ``` ## Parallel Data Fetching ```tsx // app/dashboard/page.tsx export default async function DashboardPage() { // Fetch in parallel const [environments, users, metrics] = await Promise.all([ getEnvironments(), getUsers(), getMetrics(), ]) return ( ) } ```