nextjs-expertUse when building Next.js 14/15 applications with the App Router. Invoke for routing, layouts, Server Components, Client Components, Server Actions, Route Handlers, authentication, middleware, data fetching, caching, revalidation, streaming, Suspense, loading states, error boundaries, dynamic routes, parallel routes, intercepting routes, or any Next.js architecture question.
Install via ClawdBot CLI:
clawdbot install jgarrison929/nextjs-expertComprehensive Next.js 15 App Router specialist. Adapted from buildwithclaude by Dave Poon (MIT).
You are a senior Next.js engineer specializing in the App Router, React Server Components, and production-grade full-stack applications with TypeScript.
'use client' when you need hooks, event handlers, or browser APIs.'use client' as low in the tree as possible.params and searchParams are Promise types ā always await them.| File | Purpose |
|------|---------|
| page.tsx | Unique UI for a route, makes it publicly accessible |
| layout.tsx | Shared UI wrapper, preserves state across navigations |
| loading.tsx | Loading UI using React Suspense |
| error.tsx | Error boundary for route segment (must be 'use client') |
| not-found.tsx | UI for 404 responses |
| template.tsx | Like layout but re-renders on navigation |
| default.tsx | Fallback for parallel routes |
| route.ts | API endpoint (Route Handler) |
| Pattern | Purpose | Example |
|---------|---------|---------|
| folder/ | Route segment | app/blog/ ā /blog |
| [folder]/ | Dynamic segment | app/blog/[slug]/ ā /blog/:slug |
| [...folder]/ | Catch-all segment | app/docs/[...slug]/ ā /docs/* |
| [[...folder]]/ | Optional catch-all | app/shop/[[...slug]]/ ā /shop or /shop/* |
| (folder)/ | Route group (no URL) | app/(marketing)/about/ ā /about |
| @folder/ | Named slot (parallel routes) | app/@modal/login/ |
| _folder/ | Private folder (excluded) | app/_components/ |
layout.tsx ā 2. template.tsx ā 3. error.tsx (boundary) ā 4. loading.tsx (boundary) ā 5. not-found.tsx (boundary) ā 6. page.tsx// app/about/page.tsx
export default function AboutPage() {
return (
<main>
<h1>About Us</h1>
<p>Welcome to our company.</p>
</main>
)
}
// app/blog/[slug]/page.tsx
interface PageProps {
params: Promise<{ slug: string }>
}
export default async function BlogPost({ params }: PageProps) {
const { slug } = await params
const post = await getPost(slug)
return <article>{post.content}</article>
}
// app/search/page.tsx
interface PageProps {
searchParams: Promise<{ q?: string; page?: string }>
}
export default async function SearchPage({ searchParams }: PageProps) {
const { q, page } = await searchParams
const results = await search(q, parseInt(page || '1'))
return <SearchResults results={results} />
}
export async function generateStaticParams() {
const posts = await getAllPosts()
return posts.map((post) => ({ slug: post.slug }))
}
// Allow dynamic params not in generateStaticParams
export const dynamicParams = true
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
// app/dashboard/layout.tsx
import { getUser } from '@/lib/get-user'
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const user = await getUser()
return (
<div className="flex">
<Sidebar user={user} />
<main className="flex-1 p-6">{children}</main>
</div>
)
}
app/
āāā (marketing)/
ā āāā layout.tsx # Marketing layout with <html>/<body>
ā āāā about/page.tsx
āāā (app)/
āāā layout.tsx # App layout with <html>/<body>
āāā dashboard/page.tsx
// Static
export const metadata: Metadata = {
title: 'About Us',
description: 'Learn more about our company',
}
// Dynamic
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params
const post = await getPost(slug)
return {
title: post.title,
openGraph: { title: post.title, images: [post.coverImage] },
}
}
// Template in layouts
export const metadata: Metadata = {
title: { template: '%s | Dashboard', default: 'Dashboard' },
}
Server Component (default) when:
Client Component ('use client') when:
useState, useEffect, useReduceronClick, onChange)window, document)Pattern 1: Server data ā Client interactivity
// app/products/page.tsx (Server)
export default async function ProductsPage() {
const products = await getProducts()
return <ProductFilter products={products} />
}
// components/product-filter.tsx (Client)
'use client'
export function ProductFilter({ products }: { products: Product[] }) {
const [filter, setFilter] = useState('')
const filtered = products.filter(p => p.name.includes(filter))
return (
<>
<input onChange={e => setFilter(e.target.value)} />
{filtered.map(p => <ProductCard key={p.id} product={p} />)}
</>
)
}
Pattern 2: Children as Server Components
// components/client-wrapper.tsx
'use client'
export function ClientWrapper({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false)
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
{isOpen && children}
</div>
)
}
// app/page.tsx (Server)
export default function Page() {
return (
<ClientWrapper>
<ServerContent /> {/* Still renders on server! */}
</ClientWrapper>
)
}
Pattern 3: Providers at the boundary
// app/providers.tsx
'use client'
import { ThemeProvider } from 'next-themes'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient()
export function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider attribute="class" defaultTheme="system">
{children}
</ThemeProvider>
</QueryClientProvider>
)
}
cache()import { cache } from 'react'
export const getUser = cache(async () => {
const response = await fetch('/api/user')
return response.json()
})
// Both layout and page call getUser() ā only one fetch happens
export default async function PostsPage() {
const posts = await fetch('https://api.example.com/posts').then(r => r.json())
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>
}
export default async function DashboardPage() {
const [user, posts, analytics] = await Promise.all([
getUser(), getPosts(), getAnalytics()
])
return <Dashboard user={user} posts={posts} analytics={analytics} />
}
import { Suspense } from 'react'
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<StatsSkeleton />}>
<SlowStats />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<SlowChart />
</Suspense>
</div>
)
}
// Cache indefinitely (static)
const data = await fetch('https://api.example.com/data')
// Revalidate every hour
const data = await fetch(url, { next: { revalidate: 3600 } })
// No caching (always fresh)
const data = await fetch(url, { cache: 'no-store' })
// Cache with tags
const data = await fetch(url, { next: { tags: ['posts'] } })
// app/dashboard/loading.tsx
export default function Loading() {
return (
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-1/4 mb-4" />
<div className="space-y-3">
<div className="h-4 bg-gray-200 rounded w-full" />
<div className="h-4 bg-gray-200 rounded w-5/6" />
</div>
</div>
)
}
// app/dashboard/error.tsx
'use client'
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return (
<div className="p-4 bg-red-50 border border-red-200 rounded">
<h2 className="text-red-800 font-bold">Something went wrong!</h2>
<p className="text-red-600">{error.message}</p>
<button onClick={reset} className="mt-2 px-4 py-2 bg-red-600 text-white rounded">
Try again
</button>
</div>
)
}
// app/posts/[slug]/page.tsx
import { notFound } from 'next/navigation'
export default async function PostPage({ params }: PageProps) {
const { slug } = await params
const post = await getPost(slug)
if (!post) notFound()
return <article>{post.content}</article>
}
// app/actions.ts
'use server'
import { z } from 'zod'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
const schema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
})
export async function createPost(formData: FormData) {
const session = await auth()
if (!session?.user) throw new Error('Unauthorized')
const parsed = schema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
})
if (!parsed.success) return { error: parsed.error.flatten() }
const post = await db.post.create({
data: { ...parsed.data, authorId: session.user.id },
})
revalidatePath('/posts')
redirect(`/posts/${post.slug}`)
}
// components/submit-button.tsx
'use client'
import { useFormStatus } from 'react-dom'
export function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? 'Submitting...' : 'Submit'}
</button>
)
}
// components/create-post-form.tsx
'use client'
import { useFormState } from 'react-dom'
import { createPost } from '@/app/actions'
export function CreatePostForm() {
const [state, formAction] = useFormState(createPost, {})
return (
<form action={formAction}>
<input name="title" />
{state.error?.title && <p className="text-red-500">{state.error.title[0]}</p>}
<textarea name="content" />
<SubmitButton />
</form>
)
}
'use client'
import { useOptimistic, useTransition } from 'react'
export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
const [isPending, startTransition] = useTransition()
const [optimisticTodos, addOptimistic] = useOptimistic(
initialTodos,
(state, newTodo: string) => [...state, { id: 'temp', title: newTodo, completed: false }]
)
async function handleSubmit(formData: FormData) {
const title = formData.get('title') as string
startTransition(async () => {
addOptimistic(title)
await addTodo(formData)
})
}
return (
<>
<form action={handleSubmit}>
<input name="title" />
<button>Add</button>
</form>
<ul>
{optimisticTodos.map(todo => (
<li key={todo.id} className={todo.id === 'temp' ? 'opacity-50' : ''}>{todo.title}</li>
))}
</ul>
</>
)
}
'use server'
import { revalidatePath, revalidateTag } from 'next/cache'
export async function updatePost(id: string, formData: FormData) {
await db.post.update({ where: { id }, data: { ... } })
revalidateTag(`post-${id}`) // Invalidate by cache tag
revalidatePath('/posts') // Invalidate specific page
revalidatePath(`/posts/${id}`) // Invalidate dynamic route
revalidatePath('/posts', 'layout') // Invalidate layout and all pages under it
}
// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const page = parseInt(searchParams.get('page') ?? '1')
const limit = parseInt(searchParams.get('limit') ?? '10')
const [posts, total] = await Promise.all([
db.post.findMany({ skip: (page - 1) * limit, take: limit }),
db.post.count(),
])
return NextResponse.json({ data: posts, pagination: { page, limit, total } })
}
export async function POST(request: NextRequest) {
const body = await request.json()
const post = await db.post.create({ data: body })
return NextResponse.json(post, { status: 201 })
}
// app/api/posts/[id]/route.ts
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const post = await db.post.findUnique({ where: { id } })
if (!post) return NextResponse.json({ error: 'Not found' }, { status: 404 })
return NextResponse.json(post)
}
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
await db.post.delete({ where: { id } })
return new NextResponse(null, { status: 204 })
}
export async function GET() {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
for (let i = 0; i < 10; i++) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ count: i })}\n\n`))
await new Promise(r => setTimeout(r, 1000))
}
controller.close()
},
})
return new Response(stream, {
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
})
}
app/
āāā @modal/
ā āāā (.)photo/[id]/page.tsx # Intercepted route (modal)
ā āāā default.tsx
āāā photo/[id]/page.tsx # Full page route
āāā layout.tsx
āāā page.tsx
// app/layout.tsx
export default function Layout({ children, modal }: {
children: React.ReactNode
modal: React.ReactNode
}) {
return <>{children}{modal}</>
}
'use client'
import { useRouter } from 'next/navigation'
export function Modal({ children }: { children: React.ReactNode }) {
const router = useRouter()
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center"
onClick={() => router.back()}>
<div className="bg-white rounded-lg p-6 max-w-2xl" onClick={e => e.stopPropagation()}>
{children}
</div>
</div>
)
}
// auth.ts
import NextAuth from 'next-auth'
import GitHub from 'next-auth/providers/github'
import Credentials from 'next-auth/providers/credentials'
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
GitHub({ clientId: process.env.GITHUB_ID, clientSecret: process.env.GITHUB_SECRET }),
Credentials({
credentials: { email: {}, password: {} },
authorize: async (credentials) => {
const user = await getUserByEmail(credentials.email as string)
if (!user || !await verifyPassword(credentials.password as string, user.password)) return null
return user
},
}),
],
callbacks: {
jwt: ({ token, user }) => { if (user) { token.id = user.id; token.role = user.role } return token },
session: ({ session, token }) => { session.user.id = token.id as string; session.user.role = token.role as string; return session },
},
})
// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth'
export const { GET, POST } = handlers
// middleware.ts
export { auth as middleware } from '@/auth'
export const config = {
matcher: ['/dashboard/:path*', '/api/protected/:path*'],
}
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
export default async function DashboardPage() {
const session = await auth()
if (!session) redirect('/login')
return <h1>Welcome, {session.user?.name}</h1>
}
'use server'
import { auth } from '@/auth'
export async function deletePost(id: string) {
const session = await auth()
if (!session?.user) throw new Error('Unauthorized')
const post = await db.post.findUnique({ where: { id } })
if (post?.authorId !== session.user.id) throw new Error('Forbidden')
await db.post.delete({ where: { id } })
revalidatePath('/posts')
}
export const dynamic = 'force-dynamic' // 'auto' | 'force-dynamic' | 'error' | 'force-static'
export const revalidate = 3600 // seconds
export const runtime = 'nodejs' // or 'edge'
export const maxDuration = 30 // seconds
'use client' to entire pages ā push it down to interactive leavesawait when fetches are independent ā use Promise.all()useEffect for data fetching in App Router (use async Server Components)await params in Next.js 15 (they're Promises now)loading.tsx or boundaries for async pagesGenerated Mar 1, 2026
Building a full-featured online store with dynamic product pages, shopping cart functionality, and user authentication. This requires Server Components for product listings and Client Components for interactive cart management and checkout processes.
Creating a blog or news website with dynamic routing for articles, search functionality, and admin dashboard. Server Components handle content fetching and rendering, while Client Components manage editor interfaces and real-time previews.
Developing a data-intensive dashboard for business analytics with real-time updates and interactive charts. Server Components fetch and process data, while Client Components handle chart visualizations and user interactions.
Building a learning management system with course pages, student progress tracking, and interactive quizzes. Server Components manage course content delivery, while Client Components handle quiz interactions and progress updates.
Creating a patient portal with appointment scheduling, medical record access, and secure messaging. Server Components ensure data security and privacy compliance, while Client Components provide interactive scheduling interfaces.
Web development agencies use this skill to build custom Next.js applications for clients across various industries. They charge project-based fees or retainers for ongoing maintenance and feature development.
Startups and companies build their own software products using Next.js for web applications. Revenue comes from subscription fees, enterprise licensing, or usage-based pricing models.
Experts provide consulting services to help teams implement Next.js best practices, optimize performance, and train development teams. This includes code reviews, architecture guidance, and workshops.
š¬ Integration Tip
Integrate this skill with existing React knowledge and backend systems. Focus on understanding Server Components for data fetching and Client Components for interactivity to optimize performance.
Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
Expert frontend design guidelines for creating beautiful, modern UIs. Use when building landing pages, dashboards, or any user interface.
Use when building UI with shadcn/ui components, Tailwind CSS layouts, form patterns with react-hook-form and zod, theming, dark mode, sidebar layouts, mobile navigation, or any shadcn component question.
Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when building web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
Create distinctive, production-grade static sites with React, Tailwind CSS, and shadcn/ui ā no mockups needed. Generates bold, memorable designs from plain text requirements with anti-AI-slop aesthetics, mobile-first responsive patterns, and single-file bundling. Use when building landing pages, marketing sites, portfolios, dashboards, or any static web UI. Supports both Vite (pure static) and Next.js (Vercel deploy) workflows.
AI skill for automated UI audits. Evaluate interfaces against proven UX principles for visual hierarchy, accessibility, cognitive load, navigation, and more. Based on Making UX Decisions by Tommy Geoco.