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) // 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 // 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 // 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 // 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 // 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 // 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 // app/environments/loading.tsx export default function Loading() { return (
// 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 // 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 // app/dashboard/page.tsx export default async function DashboardPage() { // Fetch in parallel const [environments, users, metrics] = await Promise.all([ getEnvironments(), getUsers(), getMetrics(), ])
return (