Next.js
Purpose
Build Next.js apps with App Router. Server Components by default. 'use client' only when required. Data fetching in Server Components with async/await.
Agent Protocol
Trigger
Exact user phrases: "Next.js", "App Router", "Server Component", "Client Component", "Next.js structure", "SSR Next.js", "SSG Next.js", "Next.js data fetching", "use server", "use client".
Input Context
Before activating, verify:
- next.config exists (Next.js 14+).
- Whether the project uses Pages Router or App Router.
Output Artifact
No file output. Produces page file structure and component code as text.
Response Format
File structure:
app/
layout.tsx
page.tsx
loading.tsx
error.tsx
users/[id]/page.tsx
Code: show component and data fetching. No import statements.
No preamble. No postamble. No explanations. No filler/hedging/transitions. Compress output.
Completion Criteria
- Server Components used by default. 'use client' only when interactivity is needed.
- Data fetching in Server Components with async/await (no useEffect for data).
- Route groups, parallel routes, or intercepting routes used appropriately.
- loading.tsx, error.tsx, not-found.tsx present for each route segment.
- Metadata API used for SEO on every page.
- Client boundaries are as small as possible (wrap interactive widgets, not pages).
Max Response Length
2560 tokens.
Component Architecture / Decision Trees
Architecture Options
| Approach | Trade-off | When to Use |
|---|---|---|
| Server Component | Zero client JS, direct DB access | Data fetching, static content |
| Client Component | Interactivity, hooks, browser APIs | Forms, animations, state |
| Route Handler (route.ts) | Standard API endpoint | Webhooks, third-party integration |
| Server Action | Form mutation, revalidation | All form submissions |
| Middleware | Edge, runs before request | Auth redirects, A/B testing |
| ISR (revalidate) | Stale-while-revalidate | Content pages, blogs |
Decision Tree: Server vs Client
Does the component need:
├── Event listeners (onClick, onChange) -> Client Component
├── Hooks (useState, useEffect, useContext) -> Client Component
├── Browser APIs (localStorage, window) -> Client Component
├── Direct DB access / file system -> Server Component
└── Fetch data from API -> Server Component
Decision Tree: Rendering Strategy
How often does the data change?
├── Never (blog post, docs) -> static (default, no revalidate)
├── Occasionally (product catalog) -> ISR with revalidate: 3600
├── On demand (CMS content) -> revalidatePath / revalidateTag
└── Every request (auth user) -> dynamic (no cache)
Decision Tree: Caching
Is the fetch result user-specific?
├── Yes -> no cache
│ ├── User in cookie -> `cache: 'no-store'`
│ └── User in header -> `cache: 'no-store'`
└── No -> cache (default)
├── Can be stale 60s -> `next: { revalidate: 60 }`
└── Never stale -> default fetch caching
Component Design Patterns
Server Component Data Fetching
// app/users/page.tsx
async function UsersPage() {
const users = await db.user.findMany({ orderBy: { createdAt: 'desc' }, take: 20 })
return (
<div>
<h1>Users</h1>
{users.map(user => <UserCard key={user.id} user={user} />)}
</div>
)
}
Client Component with Server Action
// app/users/UserForm.tsx
'use client'
import { useActionState } from 'react'
import { createUser } from './actions'
export function UserForm() {
const [state, formAction, pending] = useActionState(createUser, null)
return (
<form action={formAction}>
<input name="name" required />
{state?.error && <p>{state.error}</p>}
<button disabled={pending}>{pending ? 'Saving...' : 'Create'}</button>
</form>
)
}
Server Action
// app/users/actions.ts
'use server'
import { z } from 'zod'
const schema = z.object({ name: z.string().min(2), email: z.string().email() })
export async function createUser(_: any, formData: FormData) {
const data = Object.fromEntries(formData)
const result = schema.safeParse(data)
if (!result.success) return { error: 'Validation failed', issues: result.error.flatten().fieldErrors }
await db.user.create({ data: result.data })
revalidatePath('/users')
return { success: true }
}
Parallel Route
// app/layout.tsx
export default function Layout({ children, feed, notifications }: {
children: React.ReactNode
feed: React.ReactNode
notifications: React.ReactNode
}) {
return (
<div>
<main>{children}</main>
<aside>
{feed}
{notifications}
</aside>
</div>
)
}
Intercepting Route
// app/(.)photo/[id]/page.tsx
export default function PhotoModal({ params }: { params: { id: string } }) {
return <div className="modal"><Photo id={params.id} /></div>
}
Route Handler
// app/api/users/route.ts
export async function GET() {
const users = await db.user.findMany()
return Response.json(users)
}
export async function POST(request: Request) {
const body = await request.json()
const user = await db.user.create({ data: body })
return Response.json(user, { status: 201 })
}
State Management Patterns
Server State via Data Fetching (Primary)
async function ProfilePage() {
const user = await db.user.findUnique({ where: { id } })
return <ProfileCard user={user} />
}
URL State via searchParams
function ProductsPage({ searchParams }: { searchParams: { q?: string; page?: string } }) {
const results = await db.product.search(searchParams.q || '', { page: Number(searchParams.page) || 1 })
return <ProductList items={results} />
}
Client State with Context
// app/Providers.tsx
'use client'
import { createContext, useContext } from 'react'
const ThemeContext = createContext<'light' | 'dark'>('light')
export const useTheme = () => useContext(ThemeContext)
Client State with Zustand
// store/cart.ts
import { create } from 'zustand'
export const useCart = create<{ items: CartItem[]; add(item: CartItem): void }>((set) => ({
items: [],
add: (item) => set((s) => ({ items: [...s.items, item] })),
}))
Performance Optimization
- Server Components: zero client JS for static parts
- Streaming via Suspense boundaries — fast TTFB, progressive rendering
- Automatic route-level code splitting
- Image: next/image — WebP/AVIF, lazy loading, responsive sizes
- Font: next/font — self-hosted, no layout shift
- Route prefetching: links in viewport prefetch automatically
- Middleware runs at edge, blocks response — keep lean
- Partial Prerendering (PPR) — static shell + dynamic holes
Build & Bundle Considerations
next.config
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
images: { formats: ['image/avif', 'image/webp'] },
experimental: { ppr: true }, // Partial Prerendering
logging: { fetches: { fullUrl: true } },
serverExternalPackages: ['bcrypt'],
}
export default nextConfig
Bundle Analysis
ANALYZE=true npm run build # generates bundle report
next build --debug # verbose build output
Testing Strategies
Unit Test Server Action
import { describe, it, expect } from 'vitest'
import { createUser } from '../app/users/actions'
describe('createUser', () => {
it('validates input', async () => {
const formData = new FormData()
formData.set('name', 'A')
formData.set('email', 'invalid')
const result = await createUser(null, formData)
expect(result).toHaveProperty('issues')
expect(result.issues.name).toBeDefined()
})
})
Component Test
import { render, screen } from '@testing-library/react'
import { UserCard } from '../app/users/UserCard'
it('renders user name', () => {
render(<UserCard user={{ id: '1', name: 'Alice' }} />)
expect(screen.getByText('Alice')).toBeDefined()
})
E2E Test
import { test, expect } from '@playwright/test'
test('creates user', async ({ page }) => {
await page.goto('/users/new')
await page.fill('[name="name"]', 'Alice')
await page.click('button[type="submit"]')
await expect(page).toHaveURL('/users')
await expect(page.getByText('Alice')).toBeVisible()
})
Migration Patterns
Pages Router to App Router
// Before: Pages Router
pages/users/[id].tsx
getServerSideProps -> props
component receives props
// After: App Router
app/users/[id]/page.tsx
async function page({ params })
data fetching inline with await
// Layout migration:
pages/_app.tsx + pages/_document.tsx -> app/layout.tsx (single file)
getServerSideProps to Server Component
// Before
export const getServerSideProps = async () => {
const data = await fetchData()
return { props: { data } }
}
// After
async function Page() {
const data = await fetchData()
return <div>{data}</div>
}
getStaticProps to Static Fetch
// Before
export const getStaticProps = async () => {
const data = await fetchData()
return { props: { data }, revalidate: 60 }
}
// After
async function Page() {
const data = await fetch('https://api.com/data', { next: { revalidate: 60 } }).then(r => r.json())
return <div>{data}</div>
}
Anti-Patterns
Full Page as Client Component
// Anti-pattern
'use client'
function Page() { /* entire page client-rendered */ }
// Correct: only the interactive widget
function Page() { return <div><ClientWidget /></div> }
Fetching in useEffect
// Anti-pattern
useEffect(() => { fetch('/api/data').then(setData) }, [])
// Correct: Server Component
async function Page() { const data = await fetchData(); return <div>{data}</div> }
Nested Client Layout
If a layout.tsx has 'use client', all children become client components. Keep layouts as Server Components.
Passing Non-serializable Props
Functions, Date objects, and undefined values crash between Server and Client Components.
Common Pitfalls
- Entire page as client component: Only interactive widget needs 'use client'.
- Missing loading.tsx: Add to every route segment for streaming.
- Passing non-serializable props: Functions, Date, undefined crash SC→CC boundary.
- Fetching in useEffect: Server Components fetch with async/await.
- error.tsx must be client: Error boundaries require 'use client'.
- Mixing Pages Router and App Router: Don't mix in the same project.
- Nested client layout cascade: If layout is client, all pages under it are client.
- Parallel routes missing default.tsx: Without it, route 404s on hard navigation.
Compared With
| Aspect | App Router | Pages Router |
|---|---|---|
| Component model | Server + Client | All client |
| Data fetching | async component + fetch | getServerSideProps/getStaticProps |
| Layouts | Nested, persist | Manual per-page |
| Streaming | Native via Suspense | Not supported |
| Loading states | loading.tsx auto | Manual |
| Metadata | Built-in Metadata API | next/head |
| Mutations | Server Actions | API routes + client fetch |
Ecosystem & Tooling
next build --debug— build analysis@next/bundle-analyzer— visualize bundlesnext lint— ESLint with Next.js rulesnext dev --turbo— Turbopack@next/codemod— migration upgradesnext-sitemap— sitemaps@sentry/nextjs— error tracking
Workflow
Step 1: Server vs Client Decision
Default: Server Component. Add 'use client' only for:
onClick/onChange/onSubmit -> Client
useState/useEffect/useReducer -> Client
Browser APIs -> Client
Step 2: Route Structure
app/
layout.tsx -- Root (html, body, providers)
page.tsx -- Home
loading.tsx -- Suspense fallback
error.tsx -- Error boundary (client)
not-found.tsx -- 404
users/[id]/page.tsx -- Dynamic route
(auth)/login/page.tsx -- Route group
Step 3: Data Fetching
async function Page() {
const data = await db.query(...)
return <div>{data}</div>
}
Step 4: Mutations
'use server'
export async function action(formData: FormData) {
await db.mutate(...)
revalidatePath('/')
}
Step 5: Metadata
export const metadata: Metadata = { title: 'Page Title' }
Rules
- Server Components by default. 'use client' is the exception.
- Client boundaries are minimal: wrap the widget, not the page.
- Server Components can import Client Components, never the reverse.
- Props from SC to CC must be serializable (no functions, Date, undefined).
- Use revalidatePath / revalidateTag for cache invalidation.
- All data fetching in Server Components — never useEffect for initial data.
- Metadata on every page — use generateMetadata for dynamic routes.
References
- references/app-router-architecture.md
- references/app-router.md
- references/middleware-edge.md
- references/nextjs-data-fetching.md
- references/nextjs-deployment.md
- references/server-components.md
- references/nextjs-app-router-patterns.md
- references/nextjs-data-fetching-caching.md
Handoff
No artifact produced. Next skill: frontend-testing. Carry forward: App Router structure, data fetching pattern, Server/Client boundary decisions.