| name | nextjs-workflow |
| description | Next.js framework workflow guidelines. Activate when working with Next.js projects, next.config, app router, or Next.js-specific patterns. |
| location | user |
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.
Next.js Workflow
Tool Grid
| Task | Tool | Command |
|---|
| Run dev | Next.js | npm run dev |
| Build | Next.js | npm run build |
| Turbopack | Next.js | next dev --turbo |
| Test | Vitest | vitest |
| E2E | Playwright | playwright test |
| Lint | ESLint + next | next lint |
App Router
App Router MUST be used for all new Next.js projects. Pages Router SHOULD only be used for legacy compatibility.
Directory 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
├── (group)/ # Route groups (no URL segment)
│ └── page.tsx
├── api/ # API routes
│ └── route.ts
└── [slug]/ # Dynamic routes
└── page.tsx
Route Groups
Route groups (groupName) SHOULD be used to:
- Organize routes without affecting URL structure
- Apply different layouts to route subsets
- Split application into logical sections
Server Components
Server Components are the DEFAULT. Client Components MUST be explicitly marked.
When to Use Server Components (Default)
- Data fetching
- Accessing backend resources directly
- Keeping sensitive data on server (tokens, API keys)
- Large dependencies that SHOULD stay server-side
When to Use Client Components
Client Components MUST be marked with 'use client' directive at the top of the file.
Use Client Components for:
- Interactivity (onClick, onChange, etc.)
- React hooks (useState, useEffect, useContext)
- Browser-only APIs (localStorage, window)
- Custom hooks with state or effects
'use client'
import { useState } from 'react'
export function Counter() {
const [count, setCount] = useState(0)
return <button onClick={() => setCount(count + 1)}>{count}</button>
}
Component Composition Pattern
Server Components MAY import Client Components. Client Components MUST NOT import Server Components directly but MAY accept them as props.
import { ClientWrapper } from './ClientWrapper'
import { ServerChild } from './ServerChild'
export function ServerComponent() {
return (
<ClientWrapper>
<ServerChild /> {/* Passed as children prop */}
</ClientWrapper>
)
}
File-Based Routing
Special Files
| File | Purpose | Required |
|---|
layout.tsx | Shared UI for segment and children | Root only |
page.tsx | Unique UI for route | Yes for route |
loading.tsx | Loading UI with Suspense | OPTIONAL |
error.tsx | Error boundary | OPTIONAL |
not-found.tsx | 404 UI | OPTIONAL |
route.ts | API endpoint | OPTIONAL |
template.tsx | Re-rendered layout | OPTIONAL |
default.tsx | Parallel route fallback | OPTIONAL |
Dynamic Routes
app/
├── blog/
│ ├── [slug]/page.tsx # /blog/:slug
│ └── [...slug]/page.tsx # /blog/* (catch-all)
├── shop/
│ └── [[...slug]]/page.tsx # /shop or /shop/* (optional catch-all)
Parallel Routes
Parallel routes SHOULD be used for complex layouts with independent navigation.
app/
├── @modal/
│ └── login/page.tsx
├── @sidebar/
│ └── page.tsx
└── layout.tsx # Receives modal and sidebar as props
Intercepting Routes
app/
├── feed/
│ └── (..)photo/[id]/page.tsx # Intercepts /photo/[id]
└── photo/
└── [id]/page.tsx
Partial Prerendering (PPR)
PPR SHOULD be enabled for pages with static shells and dynamic content.
Configuration
const nextConfig = {
experimental: {
ppr: 'incremental',
},
}
Usage
export const experimental_ppr = true
export default function Page() {
return (
<main>
<StaticHeader /> {/* Prerendered */}
<Suspense fallback={<Skeleton />}>
<DynamicContent /> {/* Streamed */}
</Suspense>
</main>
)
}
Server Actions
Server Actions MUST be used for form handling and data mutations.
Inline Server Actions
export default function Page() {
async function createItem(formData: FormData) {
'use server'
const name = formData.get('name')
revalidatePath('/')
}
return (
<form action={createItem}>
<input name="name" />
<button type="submit">Create</button>
</form>
)
}
Separate Action Files
Actions MAY be defined in separate files for reuse.
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
export async function createItem(formData: FormData) {
revalidatePath('/items')
redirect('/items')
}
Client Component Usage
'use client'
import { useFormStatus } from 'react-dom'
import { createItem } from './actions'
function SubmitButton() {
const { pending } = useFormStatus()
return <button disabled={pending}>{pending ? 'Creating...' : 'Create'}</button>
}
export function CreateForm() {
return (
<form action={createItem}>
<input name="name" />
<SubmitButton />
</form>
)
}
Data Fetching
Request Deduplication
Next.js automatically deduplicates fetch requests. The same URL SHOULD be fetched in multiple components without concern.
async function Header() {
const user = await fetch('/api/user').then(r => r.json())
return <div>{user.name}</div>
}
async function Sidebar() {
const user = await fetch('/api/user').then(r => r.json())
return <div>{user.email}</div>
}
Caching Options
fetch('https://api.example.com/data')
fetch('https://api.example.com/data', { next: { revalidate: 60 } })
fetch('https://api.example.com/data', { cache: 'no-store' })
fetch('https://api.example.com/data', { next: { tags: ['posts'] } })
On-Demand Revalidation
'use server'
import { revalidatePath, revalidateTag } from 'next/cache'
export async function updatePost() {
revalidateTag('posts')
revalidatePath('/blog')
}
Image Optimization
next/image MUST be used for all images.
Basic Usage
import Image from 'next/image'
export function Avatar() {
return (
<Image
src="/avatar.jpg"
alt="User avatar"
width={64}
height={64}
priority // For LCP images
/>
)
}
Responsive Images
<Image
src="/hero.jpg"
alt="Hero image"
fill
sizes="(max-width: 768px) 100vw, 50vw"
style={{ objectFit: 'cover' }}
/>
Remote Images
Remote domains MUST be configured in next.config.ts:
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'cdn.example.com',
pathname: '/images/**',
},
],
},
}
Font Optimization
next/font SHOULD be used for optimal font loading.
Google Fonts
import { Inter, Roboto_Mono } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
display: 'swap',
})
const robotoMono = Roboto_Mono({
subsets: ['latin'],
variable: '--font-roboto-mono',
display: 'swap',
})
export default function RootLayout({ children }) {
return (
<html className={`${inter.variable} ${robotoMono.variable}`}>
<body>{children}</body>
</html>
)
}
Local Fonts
import localFont from 'next/font/local'
const myFont = localFont({
src: './fonts/MyFont.woff2',
display: 'swap',
})
Metadata API
Static Metadata
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Page Title',
description: 'Page description',
openGraph: {
title: 'OG Title',
description: 'OG Description',
images: ['/og-image.jpg'],
},
}
Dynamic Metadata
import type { Metadata } from 'next'
type Props = { params: Promise<{ slug: string }> }
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
const post = await getPost(slug)
return {
title: post.title,
description: post.excerpt,
}
}
Template Pattern
export const metadata: Metadata = {
title: {
template: '%s | My Site',
default: 'My Site',
},
}
export const metadata: Metadata = {
title: 'About',
}
Middleware
Middleware MUST be placed at middleware.ts in the project root.
Basic Middleware
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const token = request.cookies.get('token')
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
export const config = {
matcher: ['/dashboard/:path*', '/api/:path*'],
}
Headers and Rewrites
export function middleware(request: NextRequest) {
const response = NextResponse.next()
response.headers.set('x-custom-header', 'value')
if (request.nextUrl.pathname === '/old-path') {
return NextResponse.rewrite(new URL('/new-path', request.url))
}
return response
}
Environment Variables
Naming Convention
| Prefix | Availability | Example |
|---|
| None | Server only | DATABASE_URL |
NEXT_PUBLIC_ | Client + Server | NEXT_PUBLIC_API_URL |
Usage
const dbUrl = process.env.DATABASE_URL
const apiUrl = process.env.NEXT_PUBLIC_API_URL
Validation
Environment variables SHOULD be validated at build time:
import { z } from 'zod'
const envSchema = z.object({
DATABASE_URL: z.string().url(),
NEXT_PUBLIC_API_URL: z.string().url(),
})
export const env = envSchema.parse(process.env)
TypeScript Configuration
Required tsconfig.json Settings
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./*"]
}
}
}
Common Patterns
Loading States
export default function Loading() {
return <div className="animate-pulse">Loading...</div>
}
Error Boundaries
'use client'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div>
<h2>Something went wrong!</h2>
<button onClick={() => reset()}>Try again</button>
</div>
)
}
Not Found
import Link from 'next/link'
export default function NotFound() {
return (
<div>
<h2>Not Found</h2>
<Link href="/">Return Home</Link>
</div>
)
}
Programmatic Not Found
import { notFound } from 'next/navigation'
async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const item = await getItem(id)
if (!item) {
notFound()
}
return <div>{item.name}</div>
}