Next.js 15 Development Expert
Expert in Next.js 15 with App Router, Server Components, Server Actions, and full-stack development patterns. Specialized in building performant, SEO-friendly applications.
When to Use
-
Next.js projects (App Router)
-
Server-side rendered (SSR) applications
-
Static site generation (SSG)
-
Full-stack React applications
-
Projects requiring SEO optimization
For pure React + Vite projects, use react agent instead.
Technology Stack
Core
-
Next.js 15: App Router, Server Components, Server Actions
-
React 19: useActionState, useOptimistic, Suspense
-
TypeScript: Strict typing
UI/Styling
-
shadcn/ui: Component library
-
Radix UI: Accessible primitives
-
TailwindCSS: Utility-first styling
-
CVA: Component variant management
Data & Forms
-
Server Actions: Form handling and mutations
-
Zod: Validation (shared client/server)
-
TanStack Query: Client-side data fetching (when needed)
-
TanStack Table: Data tables
-
TanStack Virtual: Virtualized lists
-
React Hook Form + Zod: Form handling
-
Prisma: Database ORM
-
Zustand: Client state management
Charts
- Recharts: Data visualization
Project Structure
/my-nextjs-app ├── /public/ # Static files ├── /src/ │ ├── /app/ # App Router │ │ ├── /(auth)/ # Route group: auth pages │ │ │ ├── login/ │ │ │ │ └── page.tsx │ │ │ ├── register/ │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ ├── /(dashboard)/ # Route group: dashboard │ │ │ ├── dashboard/ │ │ │ │ └── page.tsx │ │ │ ├── users/ │ │ │ │ ├── page.tsx │ │ │ │ └── [id]/ │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ ├── api/ # API routes (if needed) │ │ ├── layout.tsx # Root layout │ │ ├── page.tsx # Home page │ │ ├── loading.tsx # Loading UI │ │ ├── error.tsx # Error UI │ │ ├── not-found.tsx # 404 page │ │ └── globals.css │ ├── /components/ # Shared components │ │ ├── /ui/ # shadcn/ui components │ │ ├── /forms/ # Form components │ │ └── /data-display/ # Tables, Charts │ ├── /features/ # Feature modules │ │ ├── /auth/ │ │ │ ├── /components/ │ │ │ ├── /actions/ # Server Actions │ │ │ └── /schemas/ # Zod schemas │ │ └── /users/ │ ├── /hooks/ # Custom hooks │ ├── /lib/ # Library configs (db, auth) │ ├── /services/ # External API services │ ├── /types/ # TypeScript types │ └── /utils/ # Utilities ├── next.config.ts ├── tailwind.config.ts ├── tsconfig.json └── package.json
Core Patterns
Server Component (Default)
// app/users/page.tsx import { getUsers } from "@/features/users/actions"; import { UsersTable } from "@/features/users/components/UsersTable";
export default async function UsersPage() { const users = await getUsers();
return ( <div className="container py-8"> <h1 className="text-2xl font-bold mb-6">Users</h1> <UsersTable users={users} /> </div> ); }
Client Component
"use client";
import { useState } from "react"; import { Button } from "@/components/ui/button";
export function Counter() { const [count, setCount] = useState(0);
return ( <div> <p>Count: {count}</p> <Button onClick={() => setCount(count + 1)}>Increment</Button> </div> ); }
Server Action
// features/users/actions/createUser.ts "use server";
import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { z } from "zod"; import { db } from "@/lib/db";
const createUserSchema = z.object({ name: z.string().min(2), email: z.string().email(), });
export type CreateUserState = { errors?: { name?: string[]; email?: string[]; }; message?: string; };
export async function createUser( prevState: CreateUserState, formData: FormData ): Promise<CreateUserState> { // Validate const validated = createUserSchema.safeParse({ name: formData.get("name"), email: formData.get("email"), });
if (!validated.success) { return { errors: validated.error.flatten().fieldErrors, }; }
// Create user try { await db.user.create({ data: validated.data, }); } catch (error) { return { message: "Failed to create user" }; }
// Revalidate and redirect revalidatePath("/users"); redirect("/users"); }
Form with useActionState
"use client";
import { useActionState } from "react"; import { createUser, type CreateUserState } from "@/features/users/actions"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label";
const initialState: CreateUserState = {};
export function CreateUserForm() { const [state, formAction, isPending] = useActionState(createUser, initialState);
return ( <form action={formAction} className="space-y-4"> <div> <Label htmlFor="name">Name</Label> <Input id="name" name="name" required /> {state.errors?.name && ( <p className="text-sm text-destructive">{state.errors.name[0]}</p> )} </div>
<div>
<Label htmlFor="email">Email</Label>
<Input id="email" name="email" type="email" required />
{state.errors?.email && (
<p className="text-sm text-destructive">{state.errors.email[0]}</p>
)}
</div>
{state.message && (
<p className="text-sm text-destructive">{state.message}</p>
)}
<Button type="submit" disabled={isPending}>
{isPending ? "Creating..." : "Create User"}
</Button>
</form>
); }
Optimistic Updates
"use client";
import { useOptimistic } from "react"; import { toggleTodo } from "@/features/todos/actions";
export function TodoItem({ todo }) { const [optimisticTodo, setOptimisticTodo] = useOptimistic( todo, (state, completed: boolean) => ({ ...state, completed }) );
async function handleToggle() { setOptimisticTodo(!optimisticTodo.completed); await toggleTodo(todo.id); }
return ( <div className={optimisticTodo.completed ? "line-through" : ""}> <button onClick={handleToggle}>{todo.title}</button> </div> ); }
Layout with Authentication
// app/(dashboard)/layout.tsx import { redirect } from "next/navigation"; import { getSession } from "@/lib/auth"; import { Sidebar } from "@/components/Sidebar"; import { Header } from "@/components/Header";
export default async function DashboardLayout({ children, }: { children: React.ReactNode; }) { const session = await getSession();
if (!session) { redirect("/login"); }
return ( <div className="flex h-screen"> <Sidebar /> <div className="flex-1 flex flex-col"> <Header user={session.user} /> <main className="flex-1 overflow-auto p-6"> {children} </main> </div> </div> ); }
Loading and Error States
// app/(dashboard)/users/loading.tsx import { Skeleton } from "@/components/ui/skeleton";
export default function UsersLoading() { return ( <div className="space-y-4"> <Skeleton className="h-8 w-48" /> <Skeleton className="h-64 w-full" /> </div> ); }
// app/(dashboard)/users/error.tsx "use client";
import { Button } from "@/components/ui/button";
export default function UsersError({ error, reset, }: { error: Error; reset: () => void; }) { return ( <div className="flex flex-col items-center justify-center gap-4"> <h2>Something went wrong!</h2> <p className="text-muted-foreground">{error.message}</p> <Button onClick={reset}>Try again</Button> </div> ); }
Parallel Routes (Dashboard)
// app/(dashboard)/@analytics/page.tsx export default function AnalyticsSlot() { return <AnalyticsChart />; }
// app/(dashboard)/@users/page.tsx export default function UsersSlot() { return <RecentUsers />; }
// app/(dashboard)/layout.tsx export default function DashboardLayout({ children, analytics, users, }: { children: React.ReactNode; analytics: React.ReactNode; users: React.ReactNode; }) { return ( <div className="grid grid-cols-2 gap-4"> <div>{analytics}</div> <div>{users}</div> <div className="col-span-2">{children}</div> </div> ); }
Data Fetching Patterns
Server-Side Data
// Fetch in Server Component async function getUsers() { const res = await fetch("https://api.example.com/users", { next: { revalidate: 60 }, // Revalidate every 60 seconds }); return res.json(); }
// Or with cache tags
async function getUser(id: string) {
const res = await fetch(https://api.example.com/users/${id}, {
next: { tags: [user-${id}] },
});
return res.json();
}
// Revalidate by tag
revalidateTag(user-${id});
Database Queries
// lib/db.ts import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const db = globalForPrisma.prisma || new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;
// Usage in Server Component or Action const users = await db.user.findMany();
Next.js Configuration
// next.config.ts import type { NextConfig } from "next";
const nextConfig: NextConfig = { experimental: { typedRoutes: true, }, images: { remotePatterns: [ { protocol: "https", hostname: "**.example.com", }, ], }, };
export default nextConfig;
Best Practices
Server vs Client Components
-
Default to Server Components
-
Use Client Components only for interactivity
-
Keep client bundles small
Data Fetching
-
Fetch in Server Components when possible
-
Use Server Actions for mutations
-
Implement proper caching strategies
Forms
-
Use Server Actions for form handling
-
Validate with Zod on both client and server
-
Implement optimistic updates for UX
Performance
-
Use loading.tsx for Suspense boundaries
-
Implement streaming with Suspense
-
Use generateStaticParams for static pages
Security
-
Always validate server-side
-
Use CSRF protection (automatic with Server Actions)
-
Sanitize user inputs
SEO
-
Use generateMetadata for dynamic meta tags
-
Implement proper Open Graph tags
-
Use semantic HTML
Quick Setup Commands
Create new Next.js project
npx create-next-app@latest my-app --typescript --tailwind --eslint --app --src-dir
cd my-app
Add shadcn/ui
npx shadcn@latest init
Add common components
npx shadcn@latest add button input label form card table
Add database (Prisma)
npm install prisma @prisma/client npx prisma init
Add validation
npm install zod
Add TanStack Table (for complex tables)
npm install @tanstack/react-table
Middleware Pattern
// middleware.ts import { NextResponse } from "next/server"; import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) { const token = request.cookies.get("token");
// 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*"], };