
Nextjs Developer
- 21 installs
- 12 repo stars
- Updated June 28, 2026
- greedychipmunk/agent-skills
Expert Next.js development with the App Router, Server Components, React 19, data fetching, routing, API routes, caching, and Server Actions.
About
A skill providing App Router expertise for production Next.js 15+ apps, covering Server vs Client Components, layouts, data fetching, caching, and Server Actions. A developer uses it when building or optimizing modern Next.js applications.
- Server Components default with explicit caching and revalidation patterns
- Covers dynamic, catch-all, parallel, and intercepting routes
Nextjs Developer by the numbers
- 21 all-time installs (skills.sh)
- Ranked #1,549 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Jul 24, 2026 (Skillselion catalog sync)
npx skills add https://github.com/greedychipmunk/agent-skills --skill nextjs-developerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 21 |
|---|---|
| repo stars | ★ 12 |
| Last updated | June 28, 2026 |
| Repository | greedychipmunk/agent-skills ↗ |
What it does
Expert Next.js development with the App Router, Server Components, React 19, data fetching, routing, API routes, caching, and Server Actions.
Files
Next.js Developer Skill
Overview
This skill provides comprehensive expertise in building production-ready Next.js applications using the App Router (Next.js 15+). It covers Server Components, React 19 support, data fetching patterns, routing, API routes, caching, and performance optimization.
Core Capabilities
App Router Architecture
- Server Components: Default rendering model for optimal performance
- Client Components: Interactive components with
"use client"directive - Layouts: Shared UI with preserved state across routes
- Templates: Fresh instances on navigation (no state preservation)
- Loading UI: Streaming with
loading.tsxfiles - Error Handling: Granular error boundaries with
error.tsx
Data Fetching
- Server-side fetching: Direct database/API access in Server Components
- Caching strategies: Dynamic rendering by default, explicit caching, and incremental regeneration
- Revalidation: Time-based and on-demand cache invalidation
- Parallel fetching: Optimized data loading patterns
Routing System
- File-based routing: Automatic route generation from file structure
- Dynamic routes:
[param]and catch-all[...slug]patterns - Route groups:
(folder)for organization without URL impact - Parallel routes:
@slotfor simultaneous route rendering - Intercepting routes: Modal patterns with
(.),(..),(...)
Server Actions
- Form handling: Progressive enhancement with
actionattribute - Mutations: Server-side data modifications
- Revalidation: Automatic cache updates after mutations
- Optimistic updates: Immediate UI feedback patterns
Implementation Patterns
Project Structure
app/
├── layout.tsx # Root layout
├── page.tsx # Home page
├── globals.css # Global styles
├── (auth)/ # Route group
│ ├── login/page.tsx
│ └── register/page.tsx
├── dashboard/
│ ├── layout.tsx # Dashboard layout
│ ├── page.tsx # Dashboard home
│ ├── loading.tsx # Loading UI
│ ├── error.tsx # Error boundary
│ └── [id]/page.tsx # Dynamic route
├── api/
│ └── [route]/route.ts # API routes
└── components/ # Shared componentsComponent Patterns
Server Component (Default)
// app/posts/page.tsx
async function PostsPage() {
const posts = await db.posts.findMany()
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
export default PostsPageClient Component
// app/components/counter.tsx
"use client"
import { useState } from "react"
export function Counter() {
const [count, setCount] = useState(0)
return (
<button onClick={() => setCount(c => c + 1)}>
Count: {count}
</button>
)
}Data Fetching Patterns
Dynamic Rendering (Default)
// In Next.js 15+, fetch is uncached by default (implicit cache: 'no-store')
async function Page() {
const data = await fetch('https://api.example.com/data')
return <div>{data}</div>
}Static Generation (Opt-in)
// Explicitly opt into caching
async function Page() {
const data = await fetch('https://api.example.com/data', {
cache: 'force-cache',
})
return <div>{data}</div>
}Time-based Revalidation
async function Page() {
const data = await fetch('https://api.example.com/data', {
next: { revalidate: 3600 }, // Revalidate every hour
})
return <div>{data}</div>
}Note: The client router cache is also uncached by default in Next.js 15, replacing the old 30s/5m defaults.
Server Actions
// app/actions.ts
"use server"
import { revalidatePath } from "next/cache"
export async function createPost(formData: FormData) {
const title = formData.get("title") as string
await db.posts.create({ data: { title } })
revalidatePath("/posts")
}// app/posts/new/page.tsx
import { createPost } from "@/app/actions"
export default function NewPost() {
return (
<form action={createPost}>
<input name="title" required />
<button type="submit">Create</button>
</form>
)
}Partial Prerendering (PPR)
- PPR combines a static shell with dynamic streaming in a single HTTP request.
- It is production-ready in Next.js 15+ and was experimental in Next.js 14.
- Enable incremental adoption with
experimental.ppr: 'incremental'innext.config.js, or useppr: truewhen you want full PPR. - Use
Suspenseboundaries to define dynamic holes inside static shells. - Adopt PPR route by route so you can gradually expand coverage without rewriting the whole app.
// app/page.tsx — static shell with dynamic hole
export const experimental_ppr = true
export default function Page() {
return (
<main>
<StaticHeader />
<Suspense fallback={<ProductSkeleton />}>
<DynamicProductList />
</Suspense>
</main>
)
}Turbopack
- Turbopack is stable for
next devin Next.js 15 and fornext buildin Next.js 15.3+. - The production build path passes 8,298 test suite cases.
- Use
next dev --turbopackandnext build --turbopackto opt in. - It can be up to 10x faster than Webpack for dev server startup.
- Some Webpack loaders and plugins may still need migration work.
next dev --turbopack
next build --turbopackReact 19 Features
- React 19 is the default runtime in Next.js 15+ App Router projects.
- Use
use()to consume promises and contexts during render. - Server Actions are stable and no longer experimental.
- Use
useFormStatus()for form state without prop drilling. - Use
useOptimistic()for optimistic UI updates.
import { use } from "react"
import { useFormStatus } from "react-dom"
import { useOptimistic } from "react"
function ProductName({ productPromise }: { productPromise: Promise<{ name: string }> }) {
const product = use(productPromise)
return <h1>{product.name}</h1>
}after() Post-Response Work
- Next.js 15 introduces
after()for post-response work. - Use it for logging, analytics, and other non-critical tasks after the response is sent.
- Import it from
next/server.
import { after } from 'next/server'
after(() => {
logAnalytics()
})Navigation Hooks (Next.js 15.4)
useLinkStatus()helps show inline link-loading indicators.onNavigatelets you track or block client-side navigation.useLinkStatus()is a client hook fromnext/linkand returns{ pending }.onNavigateis aLinkprop for SPA navigations only.
'use client'
import Link, { useLinkStatus } from 'next/link'
function LinkHint() {
const { pending } = useLinkStatus()
return <span aria-hidden>{pending ? 'Loading…' : null}</span>
}
export function Nav() {
return (
<nav>
<Link href="/dashboard" prefetch={false} onNavigate={() => trackNavigation('/dashboard')}>
Dashboard <LinkHint />
</Link>
</nav>
)
}next.config.js Patterns
- Keep experimental flags only when needed; several features have graduated to stable in Next.js 15+.
- Use
experimental.ppr: 'incremental'for route-by-route PPR adoption. - Use
ppr: trueonly when you want a fully PPR-enabled app. - Turbopack is enabled via CLI flags, not a
next.config.jsswitch.
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
experimental: {
ppr: 'incremental',
},
// For full PPR:
// ppr: true,
}
export default nextConfig{
"scripts": {
"dev": "next dev --turbopack",
"build": "next build --turbopack"
}
}Best Practices
Performance
- Use Server Components by default
- Prefer dynamic rendering unless you explicitly need caching
- Use PPR for fast static shells with dynamic holes
- Optimize images with
next/image - Use
next/fontfor font optimization - Implement streaming with Suspense boundaries
Security
- Validate all inputs in Server Actions
- Use environment variables for secrets
- Implement proper authentication patterns
- Sanitize user-generated content
Code Organization
- Colocate components with routes when possible
- Use route groups for logical organization
- Implement proper error boundaries
- Create reusable layout patterns
Scripts
This skill includes executable scripts in the scripts/ folder:
Development
- dev-server.sh: Start the development server with hot reloading
./scripts/dev-server.sh [--port PORT] [--turbo]- build-production.sh: Create optimized production build
./scripts/build-production.sh [--analyze]- analyze-bundle.sh: Analyze bundle size and dependencies
./scripts/analyze-bundle.shCode Generation
- create-page.sh: Generate a new page with optional layout/loading/error files
./scripts/create-page.sh <page-path> [--layout] [--loading] [--error]- create-api-route.sh: Generate API route handlers
./scripts/create-api-route.sh <route-name> [--dynamic]- create-component.sh: Generate React components with optional tests
./scripts/create-component.sh <name> [--client] [--test] [--dir DIR]Testing
- setup-testing.sh: Set up Jest and React Testing Library
./scripts/setup-testing.sh [--playwright]- run-tests.sh: Run tests with various options
./scripts/run-tests.sh [--watch] [--coverage] [--file FILE]Templates
This skill includes production-ready templates in the templates/ folder:
Pages
- page-server.tsx: Server Component page with async data fetching, metadata, and Suspense streaming
- page-client.tsx: Client Component page with state management, hooks, and interactivity
- page-dynamic.tsx: Dynamic route page with params, generateStaticParams, and generateMetadata
Components
- component-server.tsx: Server Component with data fetching and composition patterns
- component-client.tsx: Client Component with hooks, event handlers, and localStorage
API & Backend
- api-route.ts: Complete API route handler with CRUD, validation (Zod), auth, and CORS
- server-action.ts: Server Actions with form handling, validation, and revalidation
- middleware.ts: Middleware with auth, rate limiting, i18n, and security headers
Infrastructure
- layout.tsx: Root/nested layout with navigation, footer, metadata, and fonts
- page.test.tsx: Testing patterns for pages, components, API routes, and Server Actions
Resources
This skill includes detailed reference guides in the resources/ folder:
- app-router-patterns.md: Comprehensive App Router patterns and examples
- data-fetching.md: Data fetching strategies and caching
- server-components.md: Server vs Client Components guide
- routing-reference.md: Complete routing system reference
- performance-optimization.md: Performance best practices
- api-routes.md: API route handlers and patterns
- testing-patterns.md: Testing strategies for Next.js apps
---
Specialization: Next.js App Router Development Version: 2.0 Last Updated: May 2026
Next.js API Routes (Route Handlers)
Overview
Route Handlers allow you to create custom request handlers for a given route using the Web Request and Response APIs.
---
Basic Structure
File Convention
app/
└── api/
├── route.ts # /api
├── posts/
│ └── route.ts # /api/posts
└── posts/
└── [id]/
└── route.ts # /api/posts/[id]HTTP Methods
// app/api/posts/route.ts
export async function GET(request: Request) {}
export async function POST(request: Request) {}
export async function PUT(request: Request) {}
export async function PATCH(request: Request) {}
export async function DELETE(request: Request) {}
export async function HEAD(request: Request) {}
export async function OPTIONS(request: Request) {}---
Request Handling
Reading Request Body
export async function POST(request: Request) {
const body = await request.json()
return Response.json({
received: body,
})
}Form Data
export async function POST(request: Request) {
const formData = await request.formData()
const name = formData.get("name")
const email = formData.get("email")
return Response.json({ name, email })
}URL Search Params
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const query = searchParams.get("q")
const page = searchParams.get("page") || "1"
return Response.json({ query, page })
}Headers
import { headers } from "next/headers"
export async function GET() {
const headersList = headers()
const authorization = headersList.get("authorization")
return Response.json({ authorization })
}Cookies
import { cookies } from "next/headers"
export async function GET() {
const cookieStore = cookies()
const token = cookieStore.get("token")
return Response.json({ token: token?.value })
}
export async function POST() {
const cookieStore = cookies()
cookieStore.set("session", "abc123", {
httpOnly: true,
secure: true,
sameSite: "strict",
maxAge: 60 * 60 * 24, // 1 day
})
return Response.json({ success: true })
}---
Response Handling
JSON Response
export async function GET() {
return Response.json({ message: "Hello" })
}
// With status code
export async function POST() {
return Response.json(
{ message: "Created" },
{ status: 201 }
)
}Using NextResponse
import { NextResponse } from "next/server"
export async function GET() {
return NextResponse.json(
{ message: "Hello" },
{
status: 200,
headers: {
"Cache-Control": "max-age=3600",
},
}
)
}Redirects
import { redirect } from "next/navigation"
import { NextResponse } from "next/server"
export async function GET() {
// Option 1: Using redirect function
redirect("/new-location")
// Option 2: Using NextResponse
return NextResponse.redirect(new URL("/new-location", request.url))
}Streaming Response
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: ${i}\n\n`))
await new Promise((r) => setTimeout(r, 100))
}
controller.close()
},
})
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
})
}---
Dynamic Routes
Single Parameter
// app/api/posts/[id]/route.ts
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const post = await getPost(params.id)
if (!post) {
return Response.json(
{ error: "Not found" },
{ status: 404 }
)
}
return Response.json(post)
}Multiple Parameters
// app/api/users/[userId]/posts/[postId]/route.ts
export async function GET(
request: Request,
{ params }: { params: { userId: string; postId: string } }
) {
const { userId, postId } = params
const post = await getUserPost(userId, postId)
return Response.json(post)
}Catch-All Routes
// app/api/[...slug]/route.ts
export async function GET(
request: Request,
{ params }: { params: { slug: string[] } }
) {
// /api/a/b/c -> slug = ['a', 'b', 'c']
const path = params.slug.join("/")
return Response.json({ path })
}---
CORS
// app/api/route.ts
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
}
export async function OPTIONS() {
return Response.json({}, { headers: corsHeaders })
}
export async function GET() {
return Response.json(
{ message: "Hello" },
{ headers: corsHeaders }
)
}---
Caching
Static Route Handler
// Cached by default when using GET with no dynamic features
export async function GET() {
const data = await fetch("https://api.example.com/data")
return Response.json(data)
}Opt Out of Caching
// Option 1: Use Request object
export async function GET(request: Request) {
// Using request opts out of caching
return Response.json({ time: Date.now() })
}
// Option 2: Route segment config
export const dynamic = "force-dynamic"
export async function GET() {
return Response.json({ time: Date.now() })
}Revalidation
export const revalidate = 3600 // Revalidate every hour
export async function GET() {
const data = await fetch("https://api.example.com/data")
return Response.json(data)
}---
Error Handling
Basic Error Handling
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
try {
const post = await getPost(params.id)
if (!post) {
return Response.json(
{ error: "Post not found" },
{ status: 404 }
)
}
return Response.json(post)
} catch (error) {
console.error("Error fetching post:", error)
return Response.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}Validation Error
import { z } from "zod"
const postSchema = z.object({
title: z.string().min(1).max(100),
content: z.string().min(1),
})
export async function POST(request: Request) {
try {
const body = await request.json()
const data = postSchema.parse(body)
const post = await createPost(data)
return Response.json(post, { status: 201 })
} catch (error) {
if (error instanceof z.ZodError) {
return Response.json(
{ error: "Validation failed", details: error.errors },
{ status: 400 }
)
}
return Response.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}---
Authentication
Protected Route
import { getServerSession } from "next-auth"
import { authOptions } from "@/lib/auth"
export async function GET() {
const session = await getServerSession(authOptions)
if (!session) {
return Response.json(
{ error: "Unauthorized" },
{ status: 401 }
)
}
return Response.json({ user: session.user })
}API Key Authentication
export async function GET(request: Request) {
const apiKey = request.headers.get("x-api-key")
if (apiKey !== process.env.API_KEY) {
return Response.json(
{ error: "Invalid API key" },
{ status: 401 }
)
}
return Response.json({ data: "protected data" })
}---
File Uploads
export async function POST(request: Request) {
const formData = await request.formData()
const file = formData.get("file") as File
if (!file) {
return Response.json(
{ error: "No file provided" },
{ status: 400 }
)
}
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
// Save to filesystem or cloud storage
const path = `/uploads/${file.name}`
await writeFile(path, buffer)
return Response.json({ path })
}---
Webhooks
import crypto from "crypto"
export async function POST(request: Request) {
const body = await request.text()
const signature = request.headers.get("x-webhook-signature")
// Verify signature
const expectedSignature = crypto
.createHmac("sha256", process.env.WEBHOOK_SECRET!)
.update(body)
.digest("hex")
if (signature !== expectedSignature) {
return Response.json(
{ error: "Invalid signature" },
{ status: 401 }
)
}
const event = JSON.parse(body)
// Process webhook event
await processWebhookEvent(event)
return Response.json({ received: true })
}---
Best Practices
Structure
- Group related endpoints in folders
- Use descriptive route names
- Keep handlers focused and simple
Security
- Validate all inputs
- Use proper authentication
- Sanitize responses (don't leak sensitive data)
- Implement rate limiting
Performance
- Use appropriate caching
- Optimize database queries
- Return early on errors
Error Handling
- Return appropriate status codes
- Provide helpful error messages
- Log errors for debugging
---
Last Updated: January 2026
Next.js App Router Patterns
File Conventions
The App Router uses a file-system based router where folders define routes and special files define UI.
Special Files
| File | Purpose |
|---|---|
layout.tsx | Shared UI for segment and children |
page.tsx | Unique UI for a route (makes route accessible) |
loading.tsx | Loading UI (wraps page in Suspense) |
error.tsx | Error UI (wraps page in Error Boundary) |
not-found.tsx | Not found UI |
template.tsx | Re-rendered layout (no state preservation) |
default.tsx | Fallback for parallel routes |
route.tsx | API endpoint |
---
Layout Patterns
Root Layout (Required)
// app/layout.tsx
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}Nested Layout
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="dashboard">
<nav>
<a href="/dashboard">Overview</a>
<a href="/dashboard/settings">Settings</a>
</nav>
<main>{children}</main>
</div>
)
}Layout with Metadata
// app/blog/layout.tsx
import type { Metadata } from "next"
export const metadata: Metadata = {
title: {
template: "%s | Blog",
default: "Blog",
},
}
export default function BlogLayout({
children,
}: {
children: React.ReactNode
}) {
return <section>{children}</section>
}---
Loading States
Basic Loading UI
// app/dashboard/loading.tsx
export default function Loading() {
return <div className="skeleton">Loading...</div>
}Loading with Suspense Boundaries
// app/dashboard/page.tsx
import { Suspense } from "react"
async function SlowComponent() {
const data = await fetch("/api/slow")
return <div>{data}</div>
}
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<div>Loading stats...</div>}>
<SlowComponent />
</Suspense>
</div>
)
}---
Error Handling
Error Boundary
// app/dashboard/error.tsx
"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>
)
}Global Error Handler
// app/global-error.tsx
"use client"
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<html>
<body>
<h2>Something went wrong!</h2>
<button onClick={() => reset()}>Try again</button>
</body>
</html>
)
}Not Found Page
// app/not-found.tsx
import Link from "next/link"
export default function NotFound() {
return (
<div>
<h2>Not Found</h2>
<p>Could not find the requested resource</p>
<Link href="/">Return Home</Link>
</div>
)
}---
Route Groups
Route groups allow organizing routes without affecting the URL structure.
Organizing by Feature
app/
├── (marketing)/
│ ├── about/page.tsx # /about
│ └── blog/page.tsx # /blog
├── (shop)/
│ ├── products/page.tsx # /products
│ └── cart/page.tsx # /cart
└── layout.tsxMultiple Root Layouts
app/
├── (marketing)/
│ ├── layout.tsx # Marketing layout
│ └── page.tsx
├── (app)/
│ ├── layout.tsx # App layout
│ └── dashboard/page.tsx---
Parallel Routes
Parallel routes allow rendering multiple pages in the same layout simultaneously.
Basic Parallel Routes
app/
├── @analytics/
│ └── page.tsx
├── @team/
│ └── page.tsx
├── layout.tsx
└── page.tsx// app/layout.tsx
export default function Layout({
children,
analytics,
team,
}: {
children: React.ReactNode
analytics: React.ReactNode
team: React.ReactNode
}) {
return (
<div>
{children}
<div className="sidebar">
{analytics}
{team}
</div>
</div>
)
}Conditional Rendering with Parallel Routes
// app/layout.tsx
import { getUser } from "@/lib/auth"
export default async function Layout({
children,
admin,
user,
}: {
children: React.ReactNode
admin: React.ReactNode
user: React.ReactNode
}) {
const currentUser = await getUser()
return (
<div>
{currentUser.role === "admin" ? admin : user}
{children}
</div>
)
}---
Intercepting Routes
Intercepting routes allow loading a route within the current layout.
Convention
(.)- Match same level(..)- Match one level above(..)(..)- Match two levels above(...)- Match from root
Modal Pattern
app/
├── @modal/
│ └── (.)photo/[id]/page.tsx # Intercepted route (modal)
├── photo/
│ └── [id]/page.tsx # Direct access (full page)
├── layout.tsx
└── page.tsx// app/@modal/(.)photo/[id]/page.tsx
import { Modal } from "@/components/modal"
export default function PhotoModal({
params,
}: {
params: { id: string }
}) {
return (
<Modal>
<img src={`/photos/${params.id}`} alt="" />
</Modal>
)
}// app/layout.tsx
export default function Layout({
children,
modal,
}: {
children: React.ReactNode
modal: React.ReactNode
}) {
return (
<>
{children}
{modal}
</>
)
}---
Template Pattern
Templates create a new instance on navigation (unlike layouts).
// app/template.tsx
export default function Template({
children,
}: {
children: React.ReactNode
}) {
return <div className="fade-in">{children}</div>
}Use Cases:
- Enter/exit animations
- Features relying on useEffect (logging page views)
- Features relying on useState (per-page feedback form)
---
Metadata Patterns
Static Metadata
// app/page.tsx
import type { Metadata } from "next"
export const metadata: Metadata = {
title: "Home",
description: "Welcome to our website",
openGraph: {
title: "Home",
description: "Welcome to our website",
images: ["/og-image.jpg"],
},
}Dynamic Metadata
// app/posts/[id]/page.tsx
import type { Metadata, ResolvingMetadata } from "next"
type Props = {
params: { id: string }
}
export async function generateMetadata(
{ params }: Props,
parent: ResolvingMetadata
): Promise<Metadata> {
const post = await getPost(params.id)
return {
title: post.title,
description: post.excerpt,
openGraph: {
images: [post.image, ...(await parent).openGraph?.images || []],
},
}
}---
Last Updated: January 2026
Next.js Data Fetching Guide
Overview
Next.js App Router provides powerful data fetching capabilities with automatic caching and revalidation.
---
Server Component Data Fetching
Basic Fetch
// app/posts/page.tsx
async function getPosts() {
const res = await fetch("https://api.example.com/posts")
if (!res.ok) {
throw new Error("Failed to fetch posts")
}
return res.json()
}
export default async function PostsPage() {
const posts = await getPosts()
return (
<ul>
{posts.map((post: Post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}Direct Database Access
// app/users/page.tsx
import { db } from "@/lib/db"
export default async function UsersPage() {
const users = await db.user.findMany()
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}---
Caching Strategies
Static Data (Default)
// Cached indefinitely until revalidated
async function getData() {
const res = await fetch("https://api.example.com/data")
return res.json()
}Dynamic Data (No Cache)
// Never cached, always fresh
async function getData() {
const res = await fetch("https://api.example.com/data", {
cache: "no-store",
})
return res.json()
}Time-based Revalidation
// Revalidate every hour
async function getData() {
const res = await fetch("https://api.example.com/data", {
next: { revalidate: 3600 },
})
return res.json()
}---
Route Segment Config
Force Dynamic Rendering
// app/dashboard/page.tsx
export const dynamic = "force-dynamic"
// Options: 'auto' | 'force-dynamic' | 'error' | 'force-static'Revalidation at Route Level
// app/blog/page.tsx
export const revalidate = 3600 // Revalidate every hour
// Use 0 for no caching, false to cache indefinitelyDynamic Params
// app/posts/[id]/page.tsx
export const dynamicParams = true // Allow params not in generateStaticParams
// false = return 404 for unknown params---
On-Demand Revalidation
Revalidate by Path
// app/api/revalidate/route.ts
import { revalidatePath } from "next/cache"
import { NextRequest } from "next/server"
export async function POST(request: NextRequest) {
const path = request.nextUrl.searchParams.get("path")
if (path) {
revalidatePath(path)
return Response.json({ revalidated: true, now: Date.now() })
}
return Response.json({
revalidated: false,
message: "Missing path param",
})
}Revalidate by Tag
// Fetch with tag
async function getPosts() {
const res = await fetch("https://api.example.com/posts", {
next: { tags: ["posts"] },
})
return res.json()
}
// Revalidate the tag
import { revalidateTag } from "next/cache"
export async function POST() {
revalidateTag("posts")
return Response.json({ revalidated: true })
}---
Parallel Data Fetching
Using Promise.all
// app/dashboard/page.tsx
async function getUser(userId: string) {
const res = await fetch(`/api/users/${userId}`)
return res.json()
}
async function getPosts(userId: string) {
const res = await fetch(`/api/users/${userId}/posts`)
return res.json()
}
export default async function Dashboard({
params,
}: {
params: { userId: string }
}) {
// Fetch in parallel
const [user, posts] = await Promise.all([
getUser(params.userId),
getPosts(params.userId),
])
return (
<div>
<h1>{user.name}</h1>
<ul>
{posts.map((post: Post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
)
}Using Suspense for Streaming
// app/dashboard/page.tsx
import { Suspense } from "react"
async function UserInfo({ userId }: { userId: string }) {
const user = await getUser(userId)
return <h1>{user.name}</h1>
}
async function UserPosts({ userId }: { userId: string }) {
const posts = await getPosts(userId)
return (
<ul>
{posts.map((post: Post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
export default function Dashboard({
params,
}: {
params: { userId: string }
}) {
return (
<div>
<Suspense fallback={<div>Loading user...</div>}>
<UserInfo userId={params.userId} />
</Suspense>
<Suspense fallback={<div>Loading posts...</div>}>
<UserPosts userId={params.userId} />
</Suspense>
</div>
)
}---
Sequential Data Fetching
When data depends on previous results:
// app/artist/[id]/page.tsx
async function getArtist(id: string) {
const res = await fetch(`/api/artists/${id}`)
return res.json()
}
async function getAlbums(artistId: string) {
const res = await fetch(`/api/artists/${artistId}/albums`)
return res.json()
}
export default async function ArtistPage({
params,
}: {
params: { id: string }
}) {
// Sequential: albums depend on artist
const artist = await getArtist(params.id)
const albums = await getAlbums(artist.id)
return (
<div>
<h1>{artist.name}</h1>
<ul>
{albums.map((album: Album) => (
<li key={album.id}>{album.title}</li>
))}
</ul>
</div>
)
}---
Preloading Data
// lib/data.ts
import { cache } from "react"
export const getUser = cache(async (id: string) => {
const res = await fetch(`/api/users/${id}`)
return res.json()
})
export const preloadUser = (id: string) => {
void getUser(id)
}// app/user/[id]/page.tsx
import { getUser, preloadUser } from "@/lib/data"
export default async function UserPage({
params,
}: {
params: { id: string }
}) {
// Start loading early
preloadUser(params.id)
// ... other work
const user = await getUser(params.id)
return <div>{user.name}</div>
}---
Static Generation
generateStaticParams
// app/posts/[id]/page.tsx
export async function generateStaticParams() {
const posts = await fetch("https://api.example.com/posts").then((res) =>
res.json()
)
return posts.map((post: Post) => ({
id: post.id.toString(),
}))
}
export default async function PostPage({
params,
}: {
params: { id: string }
}) {
const post = await getPost(params.id)
return <article>{post.content}</article>
}Generating Multiple Params
// app/[lang]/[slug]/page.tsx
export async function generateStaticParams() {
const products = await getProducts()
return products.flatMap((product) =>
["en", "es", "fr"].map((lang) => ({
lang,
slug: product.slug,
}))
)
}---
Request Memoization
React automatically memoizes fetch requests with the same URL and options:
// This fetch is called in multiple components
async function getItem(id: string) {
// Only one request is made even if called multiple times
const res = await fetch(`/api/items/${id}`)
return res.json()
}
// Component A
async function ComponentA({ id }: { id: string }) {
const item = await getItem(id) // Request 1
return <div>{item.name}</div>
}
// Component B
async function ComponentB({ id }: { id: string }) {
const item = await getItem(id) // Deduplicated, uses cached result
return <div>{item.description}</div>
}---
Error Handling
Try-Catch Pattern
async function getData() {
try {
const res = await fetch("https://api.example.com/data")
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`)
}
return res.json()
} catch (error) {
console.error("Fetch error:", error)
throw error // Re-throw to trigger error boundary
}
}With Error Boundary
// app/posts/error.tsx
"use client"
export default function Error({
error,
reset,
}: {
error: Error
reset: () => void
}) {
return (
<div>
<h2>Failed to load posts</h2>
<p>{error.message}</p>
<button onClick={() => reset()}>Retry</button>
</div>
)
}---
Last Updated: January 2026
Next.js Performance Optimization
Overview
Next.js provides built-in performance optimizations. This guide covers best practices for maximizing application performance.
---
Image Optimization
Using next/image
import Image from "next/image"
export function Hero() {
return (
<Image
src="/hero.jpg"
alt="Hero image"
width={1200}
height={600}
priority // Load immediately for LCP
/>
)
}Responsive Images
<Image
src="/photo.jpg"
alt="Photo"
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
className="object-cover"
/>Remote Images
// next.config.js
module.exports = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "cdn.example.com",
pathname: "/images/**",
},
],
},
}Image Props Reference
| Prop | Purpose |
|---|---|
priority | Preload image (use for LCP) |
loading="lazy" | Lazy load (default for non-priority) |
placeholder="blur" | Show blur while loading |
quality={75} | Image quality (1-100) |
fill | Fill parent container |
sizes | Responsive size hints |
---
Font Optimization
Using next/font
// app/layout.tsx
import { Inter, Roboto_Mono } from "next/font/google"
const inter = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-inter",
})
const robotoMono = Roboto_Mono({
subsets: ["latin"],
display: "swap",
variable: "--font-roboto-mono",
})
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" className={`${inter.variable} ${robotoMono.variable}`}>
<body>{children}</body>
</html>
)
}Local Fonts
import localFont from "next/font/local"
const myFont = localFont({
src: "./my-font.woff2",
display: "swap",
})
export default function Layout({ children }) {
return (
<html className={myFont.className}>
<body>{children}</body>
</html>
)
}---
Script Optimization
Using next/script
import Script from "next/script"
export default function Page() {
return (
<>
{/* Load after page is interactive */}
<Script
src="https://analytics.example.com/script.js"
strategy="afterInteractive"
/>
{/* Load during browser idle time */}
<Script
src="https://widget.example.com/script.js"
strategy="lazyOnload"
/>
{/* Block page load (rarely needed) */}
<Script
src="https://critical.example.com/script.js"
strategy="beforeInteractive"
/>
</>
)
}Script Strategies
| Strategy | When to Use |
|---|---|
beforeInteractive | Critical scripts that must load before hydration |
afterInteractive | Analytics, tracking (default) |
lazyOnload | Low-priority scripts |
worker | Load in web worker (experimental) |
---
Caching Strategies
Static Data (Default)
// Cached indefinitely
async function getData() {
const res = await fetch("https://api.example.com/data")
return res.json()
}Time-Based Revalidation
// Revalidate every hour
async function getData() {
const res = await fetch("https://api.example.com/data", {
next: { revalidate: 3600 },
})
return res.json()
}On-Demand Revalidation
// app/api/revalidate/route.ts
import { revalidateTag, revalidatePath } from "next/cache"
export async function POST(request: Request) {
const { tag, path } = await request.json()
if (tag) {
revalidateTag(tag)
} else if (path) {
revalidatePath(path)
}
return Response.json({ revalidated: true })
}Route Segment Config
// Force dynamic rendering
export const dynamic = "force-dynamic"
// Force static rendering
export const dynamic = "force-static"
// Set revalidation time
export const revalidate = 3600---
Bundle Optimization
Dynamic Imports
import dynamic from "next/dynamic"
// Load component only when needed
const HeavyChart = dynamic(() => import("./heavy-chart"), {
loading: () => <div>Loading chart...</div>,
ssr: false, // Disable SSR for client-only components
})
export default function Dashboard() {
return <HeavyChart />
}Named Exports
const Chart = dynamic(
() => import("./charts").then((mod) => mod.LineChart),
{ loading: () => <p>Loading...</p> }
)Code Splitting by Route
Routes are automatically code-split. Each page only loads its dependencies.
---
Streaming and Suspense
Loading UI
// app/dashboard/loading.tsx
export default function Loading() {
return <DashboardSkeleton />
}Granular Suspense
import { Suspense } from "react"
export default function Page() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<StatsSkeleton />}>
<Stats />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<Chart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<DataTable />
</Suspense>
</div>
)
}---
Server Components
Benefits
- Zero JavaScript sent to client
- Direct backend access
- Improved initial load time
- Better SEO
Best Practices
// GOOD: Server Component (default)
export default async function PostList() {
const posts = await db.post.findMany()
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>
}
// Only use "use client" when needed
"use client"
export function InteractiveButton() {
const [clicked, setClicked] = useState(false)
return <button onClick={() => setClicked(true)}>Click</button>
}---
Prefetching
Automatic Prefetching
Links in viewport are automatically prefetched:
import Link from "next/link"
// Prefetched automatically when visible
<Link href="/about">About</Link>
// Disable prefetching
<Link href="/heavy-page" prefetch={false}>Heavy Page</Link>Manual Prefetching
"use client"
import { useRouter } from "next/navigation"
export function PrefetchButton() {
const router = useRouter()
return (
<button
onMouseEnter={() => router.prefetch("/dashboard")}
onClick={() => router.push("/dashboard")}
>
Go to Dashboard
</button>
)
}---
Metadata Optimization
Static Metadata
export const metadata = {
title: "My Site",
description: "Welcome to my site",
openGraph: {
title: "My Site",
description: "Welcome to my site",
images: ["/og-image.jpg"],
},
}Dynamic Metadata
export async function generateMetadata({ params }) {
const post = await getPost(params.id)
return {
title: post.title,
description: post.excerpt,
}
}---
Build Optimization
Analyze Bundle
# Install analyzer
npm install @next/bundle-analyzer
# next.config.js
const withBundleAnalyzer = require("@next/bundle-analyzer")({
enabled: process.env.ANALYZE === "true",
})
module.exports = withBundleAnalyzer({})
# Run analysis
ANALYZE=true npm run buildOptimize Dependencies
// next.config.js
module.exports = {
// Reduce moment.js bundle size
webpack: (config) => {
config.resolve.alias = {
...config.resolve.alias,
"moment/locale": false,
}
return config
},
}---
Performance Checklist
Images
- [ ] Use
next/imagefor all images - [ ] Add
priorityto LCP image - [ ] Provide
sizesfor responsive images - [ ] Use appropriate quality settings
Fonts
- [ ] Use
next/fontfor all fonts - [ ] Specify
display: swap - [ ] Subset fonts to needed characters
JavaScript
- [ ] Use Server Components by default
- [ ] Dynamic import heavy components
- [ ] Analyze bundle size regularly
Data Fetching
- [ ] Implement appropriate caching
- [ ] Use parallel data fetching
- [ ] Stream content with Suspense
Core Web Vitals
- [ ] Optimize LCP (largest contentful paint)
- [ ] Minimize CLS (cumulative layout shift)
- [ ] Reduce FID/INP (interaction delay)
---
Last Updated: January 2026
Next.js Routing Reference
File-Based Routing
Next.js App Router uses a file-system based router where:
- Folders define routes
- Files define UI
---
Basic Routes
Static Routes
app/
├── page.tsx # /
├── about/
│ └── page.tsx # /about
└── blog/
└── page.tsx # /blogNested Routes
app/
└── blog/
├── page.tsx # /blog
└── posts/
└── page.tsx # /blog/posts---
Dynamic Routes
Single Parameter
app/
└── posts/
└── [id]/
└── page.tsx # /posts/1, /posts/2, etc.// app/posts/[id]/page.tsx
export default function PostPage({
params,
}: {
params: { id: string }
}) {
return <div>Post: {params.id}</div>
}Multiple Parameters
app/
└── shop/
└── [category]/
└── [product]/
└── page.tsx # /shop/electronics/phone// app/shop/[category]/[product]/page.tsx
export default function ProductPage({
params,
}: {
params: { category: string; product: string }
}) {
return (
<div>
Category: {params.category}
Product: {params.product}
</div>
)
}Catch-All Segments
app/
└── docs/
└── [...slug]/
└── page.tsx # /docs/a, /docs/a/b, /docs/a/b/c// app/docs/[...slug]/page.tsx
export default function DocsPage({
params,
}: {
params: { slug: string[] }
}) {
// /docs/a/b/c -> slug = ['a', 'b', 'c']
return <div>Path: {params.slug.join("/")}</div>
}Optional Catch-All
app/
└── docs/
└── [[...slug]]/
└── page.tsx # /docs, /docs/a, /docs/a/b---
Route Groups
Groups organize routes without affecting the URL.
Syntax: (folderName)
app/
├── (marketing)/
│ ├── about/page.tsx # /about
│ └── contact/page.tsx # /contact
├── (shop)/
│ ├── products/page.tsx # /products
│ └── cart/page.tsx # /cart
└── page.tsx # /Multiple Layouts
app/
├── (marketing)/
│ ├── layout.tsx # Marketing layout
│ ├── about/page.tsx
│ └── blog/page.tsx
├── (app)/
│ ├── layout.tsx # App layout (authenticated)
│ ├── dashboard/page.tsx
│ └── settings/page.tsx
└── layout.tsx # Root layout---
Parallel Routes
Render multiple pages simultaneously in the same layout.
Syntax: @folderName
app/
├── @dashboard/
│ └── page.tsx
├── @analytics/
│ └── page.tsx
├── layout.tsx
└── page.tsx// app/layout.tsx
export default function Layout({
children,
dashboard,
analytics,
}: {
children: React.ReactNode
dashboard: React.ReactNode
analytics: React.ReactNode
}) {
return (
<div>
{children}
<div className="panels">
{dashboard}
{analytics}
</div>
</div>
)
}Default Files
app/
├── @team/
│ ├── page.tsx # Shown at /
│ └── settings/page.tsx # Shown at /settings
├── @analytics/
│ ├── page.tsx
│ └── default.tsx # Fallback when no match
└── layout.tsx---
Intercepting Routes
Load a route within the current layout (modal patterns).
Convention
| Pattern | Matches |
|---|---|
(.) | Same level |
(..) | One level up |
(..)(..) | Two levels up |
(...) | Root app directory |
Modal Example
app/
├── @modal/
│ └── (.)photo/
│ └── [id]/
│ └── page.tsx # Modal view
├── photo/
│ └── [id]/
│ └── page.tsx # Full page view
├── layout.tsx
└── page.tsx// app/layout.tsx
export default function Layout({
children,
modal,
}: {
children: React.ReactNode
modal: React.ReactNode
}) {
return (
<>
{children}
{modal}
</>
)
}---
Navigation
Link Component
import Link from "next/link"
export function Navigation() {
return (
<nav>
<Link href="/">Home</Link>
<Link href="/about">About</Link>
<Link href="/posts/1">Post 1</Link>
<Link href={{ pathname: "/posts", query: { sort: "asc" } }}>
Posts (sorted)
</Link>
</nav>
)
}Programmatic Navigation
"use client"
import { useRouter } from "next/navigation"
export function LoginButton() {
const router = useRouter()
function handleLogin() {
// ... authenticate
router.push("/dashboard")
}
return <button onClick={handleLogin}>Login</button>
}Router Methods
const router = useRouter()
router.push("/dashboard") // Navigate to route
router.replace("/login") // Replace current history entry
router.refresh() // Refresh current route
router.prefetch("/about") // Prefetch route
router.back() // Go back
router.forward() // Go forward---
Route Handlers (API Routes)
Basic Handler
// app/api/posts/route.ts
import { NextResponse } from "next/server"
export async function GET() {
const posts = await getPosts()
return NextResponse.json(posts)
}
export async function POST(request: Request) {
const body = await request.json()
const post = await createPost(body)
return NextResponse.json(post, { status: 201 })
}Dynamic Route Handler
// app/api/posts/[id]/route.ts
import { NextResponse } from "next/server"
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const post = await getPost(params.id)
if (!post) {
return NextResponse.json(
{ error: "Post not found" },
{ status: 404 }
)
}
return NextResponse.json(post)
}---
Hooks
usePathname
"use client"
import { usePathname } from "next/navigation"
export function NavLink({ href, children }) {
const pathname = usePathname()
const isActive = pathname === href
return (
<a href={href} className={isActive ? "active" : ""}>
{children}
</a>
)
}useSearchParams
"use client"
import { useSearchParams } from "next/navigation"
export function SearchResults() {
const searchParams = useSearchParams()
const query = searchParams.get("q")
return <div>Searching for: {query}</div>
}useParams
"use client"
import { useParams } from "next/navigation"
export function PostInfo() {
const params = useParams()
// For /posts/[id] -> params.id
return <div>Post ID: {params.id}</div>
}useSelectedLayoutSegment
"use client"
import { useSelectedLayoutSegment } from "next/navigation"
export function NavTabs() {
const segment = useSelectedLayoutSegment()
// Returns the active child segment
return (
<nav>
<a className={segment === "posts" ? "active" : ""}>Posts</a>
<a className={segment === "users" ? "active" : ""}>Users</a>
</nav>
)
}---
Redirects
In Server Components
import { redirect } from "next/navigation"
export default async function Page() {
const user = await getUser()
if (!user) {
redirect("/login")
}
return <div>Welcome {user.name}</div>
}In Route Handlers
import { redirect } from "next/navigation"
export async function GET(request: Request) {
const session = await getSession()
if (!session) {
redirect("/login")
}
// ...
}Permanent Redirect
import { permanentRedirect } from "next/navigation"
export default function Page() {
permanentRedirect("/new-location")
}---
Middleware
// middleware.ts (at project root)
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
export function middleware(request: NextRequest) {
// Check auth
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*"],
}---
Last Updated: January 2026
Server Components vs Client Components
Overview
Next.js App Router uses React Server Components by default. Understanding when to use Server vs Client Components is crucial for building performant applications.
---
Server Components (Default)
Server Components render on the server and send HTML to the client.
Benefits
- Zero JavaScript: No JS bundle sent to client for the component
- Direct backend access: Query databases, access file system
- Improved security: Keep sensitive data on server
- Caching: Results can be cached and reused
- Better SEO: Full HTML available for crawlers
Capabilities
- Fetch data directly
- Access backend resources
- Keep sensitive information server-side
- Import large dependencies without client impact
Limitations
- Cannot use hooks (useState, useEffect)
- Cannot use browser APIs
- Cannot add event handlers (onClick, onChange)
- Cannot use Context providers
Example
// app/posts/page.tsx (Server Component by default)
import { db } from "@/lib/db"
export default async function PostsPage() {
// Direct database access
const posts = await db.post.findMany({
include: { author: true },
})
return (
<ul>
{posts.map((post) => (
<li key={post.id}>
<h2>{post.title}</h2>
<p>By {post.author.name}</p>
</li>
))}
</ul>
)
}---
Client Components
Client Components are pre-rendered on the server and hydrated on the client for interactivity.
When to Use
- Interactivity: onClick, onChange handlers
- State: useState, useReducer
- Effects: useEffect, useLayoutEffect
- Browser APIs: window, document, localStorage
- Custom hooks: Hooks that depend on state/effects
- React Class components: If using legacy patterns
Declaration
"use client" // Add at the top of the file
import { useState } from "react"
export function Counter() {
const [count, setCount] = useState(0)
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
)
}---
Composition Patterns
Pattern 1: Server Component with Client Children
// app/dashboard/page.tsx (Server Component)
import { db } from "@/lib/db"
import { InteractiveChart } from "./chart"
export default async function Dashboard() {
const data = await db.analytics.findMany()
return (
<div>
<h1>Dashboard</h1>
{/* Pass server data to client component */}
<InteractiveChart data={data} />
</div>
)
}// app/dashboard/chart.tsx (Client Component)
"use client"
import { useState } from "react"
export function InteractiveChart({ data }: { data: DataPoint[] }) {
const [selectedRange, setSelectedRange] = useState("week")
return (
<div>
<select
value={selectedRange}
onChange={(e) => setSelectedRange(e.target.value)}
>
<option value="day">Day</option>
<option value="week">Week</option>
<option value="month">Month</option>
</select>
{/* Render chart with data */}
</div>
)
}Pattern 2: Client Component Wrapping Server Children
// app/page.tsx (Server Component)
import { Modal } from "@/components/modal"
import { ServerContent } from "./server-content"
export default function Page() {
return (
<Modal>
{/* Server Component passed as children */}
<ServerContent />
</Modal>
)
}// components/modal.tsx (Client Component)
"use client"
import { useState } from "react"
export function Modal({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<button onClick={() => setIsOpen(true)}>Open</button>
{isOpen && (
<div className="modal">
{children}
<button onClick={() => setIsOpen(false)}>Close</button>
</div>
)}
</>
)
}---
Decision Guide
| Need | Component Type |
|---|---|
| Fetch data | Server |
| Access backend resources | Server |
| Keep sensitive info on server | Server |
| Large dependencies | Server |
| Add interactivity (onClick) | Client |
| Use state (useState) | Client |
| Use effects (useEffect) | Client |
| Use browser APIs | Client |
| Use custom hooks with state | Client |
---
Common Patterns
Passing Server Data to Client Components
// Server Component
export default async function Page() {
const user = await getUser()
// Serialize and pass to client
return <UserProfile user={user} />
}
// Client Component
"use client"
export function UserProfile({ user }: { user: User }) {
const [isEditing, setIsEditing] = useState(false)
// user data is already fetched, just handle UI state
}Moving Client Components Down
// BAD: Entire page is client component
"use client"
export default function Page() {
const [search, setSearch] = useState("")
const posts = usePosts() // Client-side fetching
return (
<div>
<input value={search} onChange={(e) => setSearch(e.target.value)} />
<PostList posts={posts} />
</div>
)
}// GOOD: Only SearchBar is client component
// app/posts/page.tsx (Server Component)
export default async function Page() {
const posts = await getPosts() // Server-side fetching
return (
<div>
<SearchBar /> {/* Client component */}
<PostList posts={posts} /> {/* Server component */}
</div>
)
}
// app/posts/search-bar.tsx
"use client"
export function SearchBar() {
const [search, setSearch] = useState("")
// Handle search with URL params or server action
}Context Providers
// app/providers.tsx
"use client"
import { ThemeProvider } from "next-themes"
export function Providers({ children }: { children: React.ReactNode }) {
return (
<ThemeProvider attribute="class" defaultTheme="system">
{children}
</ThemeProvider>
)
}
// app/layout.tsx (Server Component)
import { Providers } from "./providers"
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html>
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}---
Third-Party Libraries
Many third-party components need "use client" because they use hooks.
Wrapping Client-Only Libraries
// components/carousel.tsx
"use client"
import { Carousel as LibCarousel } from "some-carousel-lib"
export function Carousel(props: CarouselProps) {
return <LibCarousel {...props} />
}Using in Server Components
// app/page.tsx (Server Component)
import { Carousel } from "@/components/carousel"
export default async function Page() {
const images = await getImages()
return (
<div>
<h1>Gallery</h1>
<Carousel images={images} />
</div>
)
}---
Serialization Rules
When passing props from Server to Client Components, data must be serializable:
Supported Types
- Primitives (string, number, boolean, null, undefined)
- Arrays and objects containing serializable values
- Date (serialized to string)
- Map, Set (serialized)
- TypedArrays, ArrayBuffer
Not Supported
- Functions
- Classes (instances)
- Symbols
- React elements (except as children)
// This works
<ClientComponent
data={{ name: "John", count: 42 }}
items={["a", "b", "c"]}
date={new Date()}
/>
// This does NOT work
<ClientComponent
onClick={() => console.log("clicked")} // Function
user={new User("John")} // Class instance
/>---
Last Updated: January 2026
Next.js Testing Patterns
Overview
This guide covers testing strategies for Next.js applications using Jest, React Testing Library, and Playwright.
---
Setup
Jest Configuration
// jest.config.js
const nextJest = require("next/jest")
const createJestConfig = nextJest({
dir: "./",
})
const customJestConfig = {
setupFilesAfterEnv: ["<rootDir>/jest.setup.js"],
testEnvironment: "jest-environment-jsdom",
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
},
}
module.exports = createJestConfig(customJestConfig)Jest Setup
// jest.setup.js
import "@testing-library/jest-dom"Package Installation
npm install --save-dev jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom---
Component Testing
Basic Component Test
// components/button.test.tsx
import { render, screen, fireEvent } from "@testing-library/react"
import { Button } from "./button"
describe("Button", () => {
it("renders button with text", () => {
render(<Button>Click me</Button>)
expect(screen.getByRole("button")).toHaveTextContent("Click me")
})
it("calls onClick when clicked", () => {
const handleClick = jest.fn()
render(<Button onClick={handleClick}>Click me</Button>)
fireEvent.click(screen.getByRole("button"))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it("is disabled when disabled prop is true", () => {
render(<Button disabled>Click me</Button>)
expect(screen.getByRole("button")).toBeDisabled()
})
})Testing with User Events
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { SearchInput } from "./search-input"
describe("SearchInput", () => {
it("updates value on user input", async () => {
const user = userEvent.setup()
const handleSearch = jest.fn()
render(<SearchInput onSearch={handleSearch} />)
const input = screen.getByRole("textbox")
await user.type(input, "hello")
expect(input).toHaveValue("hello")
})
it("submits on enter key", async () => {
const user = userEvent.setup()
const handleSearch = jest.fn()
render(<SearchInput onSearch={handleSearch} />)
await user.type(screen.getByRole("textbox"), "test{enter}")
expect(handleSearch).toHaveBeenCalledWith("test")
})
})---
Testing Async Components
Server Component Testing
// app/posts/page.test.tsx
import { render, screen } from "@testing-library/react"
import PostsPage from "./page"
// Mock the data fetching
jest.mock("@/lib/db", () => ({
db: {
post: {
findMany: jest.fn().mockResolvedValue([
{ id: "1", title: "Post 1" },
{ id: "2", title: "Post 2" },
]),
},
},
}))
describe("PostsPage", () => {
it("renders posts from database", async () => {
const PostsPageComponent = await PostsPage()
render(PostsPageComponent)
expect(screen.getByText("Post 1")).toBeInTheDocument()
expect(screen.getByText("Post 2")).toBeInTheDocument()
})
})Testing with Suspense
import { render, screen, waitFor } from "@testing-library/react"
import { Suspense } from "react"
import { PostList } from "./post-list"
describe("PostList", () => {
it("shows loading state then content", async () => {
render(
<Suspense fallback={<div>Loading...</div>}>
<PostList />
</Suspense>
)
expect(screen.getByText("Loading...")).toBeInTheDocument()
await waitFor(() => {
expect(screen.getByText("Post 1")).toBeInTheDocument()
})
})
})---
Testing Hooks
Custom Hook Testing
// hooks/use-counter.test.ts
import { renderHook, act } from "@testing-library/react"
import { useCounter } from "./use-counter"
describe("useCounter", () => {
it("initializes with default value", () => {
const { result } = renderHook(() => useCounter())
expect(result.current.count).toBe(0)
})
it("initializes with custom value", () => {
const { result } = renderHook(() => useCounter(10))
expect(result.current.count).toBe(10)
})
it("increments count", () => {
const { result } = renderHook(() => useCounter())
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(1)
})
it("decrements count", () => {
const { result } = renderHook(() => useCounter(5))
act(() => {
result.current.decrement()
})
expect(result.current.count).toBe(4)
})
})---
API Route Testing
Route Handler Testing
// app/api/posts/route.test.ts
import { GET, POST } from "./route"
import { NextRequest } from "next/server"
// Mock the database
jest.mock("@/lib/db", () => ({
db: {
post: {
findMany: jest.fn(),
create: jest.fn(),
},
},
}))
import { db } from "@/lib/db"
describe("POST /api/posts", () => {
beforeEach(() => {
jest.clearAllMocks()
})
it("returns all posts", async () => {
const mockPosts = [
{ id: "1", title: "Post 1" },
{ id: "2", title: "Post 2" },
]
;(db.post.findMany as jest.Mock).mockResolvedValue(mockPosts)
const response = await GET()
const data = await response.json()
expect(response.status).toBe(200)
expect(data).toEqual(mockPosts)
})
it("creates a new post", async () => {
const newPost = { id: "3", title: "New Post" }
;(db.post.create as jest.Mock).mockResolvedValue(newPost)
const request = new NextRequest("http://localhost/api/posts", {
method: "POST",
body: JSON.stringify({ title: "New Post" }),
})
const response = await POST(request)
const data = await response.json()
expect(response.status).toBe(201)
expect(data).toEqual(newPost)
})
})---
Server Actions Testing
Testing Server Actions
// app/actions.test.ts
import { createPost, deletePost } from "./actions"
import { revalidatePath } from "next/cache"
jest.mock("next/cache", () => ({
revalidatePath: jest.fn(),
}))
jest.mock("@/lib/db", () => ({
db: {
post: {
create: jest.fn(),
delete: jest.fn(),
},
},
}))
import { db } from "@/lib/db"
describe("Server Actions", () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe("createPost", () => {
it("creates post and revalidates path", async () => {
const formData = new FormData()
formData.append("title", "Test Post")
formData.append("content", "Test content")
;(db.post.create as jest.Mock).mockResolvedValue({
id: "1",
title: "Test Post",
})
await createPost(formData)
expect(db.post.create).toHaveBeenCalledWith({
data: {
title: "Test Post",
content: "Test content",
},
})
expect(revalidatePath).toHaveBeenCalledWith("/posts")
})
})
describe("deletePost", () => {
it("deletes post by id", async () => {
await deletePost("1")
expect(db.post.delete).toHaveBeenCalledWith({
where: { id: "1" },
})
})
})
})---
Integration Testing
Testing Page with Router
// app/posts/[id]/page.test.tsx
import { render, screen } from "@testing-library/react"
import PostPage from "./page"
jest.mock("next/navigation", () => ({
useRouter: () => ({
push: jest.fn(),
back: jest.fn(),
}),
useParams: () => ({ id: "1" }),
}))
jest.mock("@/lib/api", () => ({
getPost: jest.fn().mockResolvedValue({
id: "1",
title: "Test Post",
content: "Test content",
}),
}))
describe("PostPage", () => {
it("renders post content", async () => {
const PageComponent = await PostPage({ params: { id: "1" } })
render(PageComponent)
expect(screen.getByText("Test Post")).toBeInTheDocument()
expect(screen.getByText("Test content")).toBeInTheDocument()
})
})---
E2E Testing with Playwright
Setup
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test"
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
})E2E Tests
// e2e/posts.spec.ts
import { test, expect } from "@playwright/test"
test.describe("Posts", () => {
test("displays posts list", async ({ page }) => {
await page.goto("/posts")
await expect(page.getByRole("heading", { name: "Posts" })).toBeVisible()
await expect(page.getByRole("listitem")).toHaveCount(3)
})
test("creates new post", async ({ page }) => {
await page.goto("/posts/new")
await page.getByLabel("Title").fill("New Post")
await page.getByLabel("Content").fill("Post content")
await page.getByRole("button", { name: "Create" }).click()
await expect(page).toHaveURL(/\/posts\/\d+/)
await expect(page.getByText("New Post")).toBeVisible()
})
test("navigates to post detail", async ({ page }) => {
await page.goto("/posts")
await page.getByRole("link", { name: "First Post" }).click()
await expect(page).toHaveURL("/posts/1")
await expect(page.getByText("First Post")).toBeVisible()
})
})---
Mocking Patterns
Mocking Next.js Modules
// Mock next/navigation
jest.mock("next/navigation", () => ({
useRouter: () => ({
push: jest.fn(),
replace: jest.fn(),
back: jest.fn(),
forward: jest.fn(),
refresh: jest.fn(),
prefetch: jest.fn(),
}),
usePathname: () => "/current-path",
useSearchParams: () => new URLSearchParams("q=test"),
useParams: () => ({ id: "1" }),
}))
// Mock next/headers
jest.mock("next/headers", () => ({
cookies: () => ({
get: jest.fn(),
set: jest.fn(),
delete: jest.fn(),
}),
headers: () => new Headers(),
}))Mocking Fetch
// Global fetch mock
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ data: "mocked" }),
})
) as jest.Mock
// Per-test mock
beforeEach(() => {
;(fetch as jest.Mock).mockImplementation(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve([]),
})
)
})---
Testing Best Practices
Guidelines
- Test behavior, not implementation
- Use meaningful test descriptions
- Keep tests independent
- Mock external dependencies
- Test error states and edge cases
File Organization
src/
├── components/
│ ├── button.tsx
│ └── button.test.tsx
├── app/
│ └── posts/
│ ├── page.tsx
│ └── page.test.tsx
└── e2e/
└── posts.spec.tsRunning Tests
# Unit tests
npm test
# Watch mode
npm test -- --watch
# Coverage
npm test -- --coverage
# E2E tests
npx playwright test---
Last Updated: January 2026
#!/bin/bash
# Bundle Analyzer Script for Next.js
# Analyzes the production bundle to identify size issues
# Usage: ./scripts/analyze-bundle.sh
set -e
echo "Next.js Bundle Analyzer"
echo ""
# Check if @next/bundle-analyzer is installed
if ! npm list @next/bundle-analyzer --depth=0 &>/dev/null; then
echo "Installing @next/bundle-analyzer..."
npm install --save-dev @next/bundle-analyzer
echo ""
fi
# Check if next.config.js already has bundle analyzer configured
if grep -q "bundle-analyzer" next.config.js 2>/dev/null || grep -q "bundle-analyzer" next.config.mjs 2>/dev/null; then
echo "Bundle analyzer is already configured in next.config"
else
echo "Note: You may need to configure @next/bundle-analyzer in your next.config.js"
echo ""
echo "Example configuration:"
echo ""
echo " // next.config.js"
echo " const withBundleAnalyzer = require('@next/bundle-analyzer')({"
echo " enabled: process.env.ANALYZE === 'true',"
echo " })"
echo ""
echo " module.exports = withBundleAnalyzer({"
echo " // your existing config"
echo " })"
echo ""
fi
echo "Running production build with bundle analysis..."
echo ""
ANALYZE=true npm run build
echo ""
echo "Bundle analysis complete!"
echo ""
echo "The analyzer will open in your browser showing:"
echo " - Client bundles (what's sent to the browser)"
echo " - Server bundles (server-side code)"
echo ""
echo "Tips for reducing bundle size:"
echo " 1. Use dynamic imports for large components"
echo " 2. Check for duplicate dependencies"
echo " 3. Use Server Components where possible"
echo " 4. Review third-party library sizes"
echo " 5. Use tree-shaking friendly imports"
#!/bin/bash
# Production Build Script for Next.js
# Creates an optimized production build
# Usage: ./scripts/build-production.sh [--analyze]
set -e
ANALYZE=""
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
--analyze|-a)
ANALYZE="true"
shift
;;
--help|-h)
echo "Usage: ./scripts/build-production.sh [OPTIONS]"
echo ""
echo "Options:"
echo " --analyze, -a Enable bundle analysis (requires @next/bundle-analyzer)"
echo " --help, -h Show this help message"
exit 0
;;
*)
echo "Unknown option: $1"
echo "Use --help for usage information"
exit 1
;;
esac
done
echo "Building Next.js application for production..."
echo ""
# Run type checking first
echo "Step 1: Type checking..."
if npx tsc --noEmit 2>/dev/null; then
echo "Type checking passed"
else
echo "Warning: TypeScript errors found (continuing with build)"
fi
echo ""
# Run linting
echo "Step 2: Linting..."
if npx next lint 2>/dev/null; then
echo "Linting passed"
else
echo "Warning: Linting errors found (continuing with build)"
fi
echo ""
# Build
echo "Step 3: Building..."
if [ "$ANALYZE" = "true" ]; then
echo "Bundle analysis enabled"
ANALYZE=true npx next build
else
npx next build
fi
echo ""
echo "Build completed successfully!"
echo ""
echo "To start the production server, run:"
echo " npm start"
echo " # or"
echo " npx next start"
#!/bin/bash
# Create API Route Script for Next.js App Router
# Creates a new API route handler with CRUD operations
# Usage: ./scripts/create-api-route.sh <route-name> [--dynamic]
set -e
if [ $# -eq 0 ]; then
echo "Error: Route name required"
echo "Usage: ./scripts/create-api-route.sh <route-name> [OPTIONS]"
echo ""
echo "Examples:"
echo " ./scripts/create-api-route.sh posts"
echo " ./scripts/create-api-route.sh users --dynamic"
exit 1
fi
ROUTE_NAME=$1
shift
CREATE_DYNAMIC=false
# Parse options
while [[ $# -gt 0 ]]; do
case $1 in
--dynamic|-d)
CREATE_DYNAMIC=true
shift
;;
--help|-h)
echo "Usage: ./scripts/create-api-route.sh <route-name> [OPTIONS]"
echo ""
echo "Options:"
echo " --dynamic, -d Also create [id]/route.ts for single item operations"
echo " --help, -h Show this help message"
exit 0
;;
*)
echo "Unknown option: $1"
exit 1
;;
esac
done
ROUTE_DIR="app/api/$ROUTE_NAME"
# Check if route already exists
if [ -d "$ROUTE_DIR" ]; then
echo "Error: API route '$ROUTE_NAME' already exists at $ROUTE_DIR"
exit 1
fi
echo "Creating API route: $ROUTE_NAME"
# Create directory
mkdir -p "$ROUTE_DIR"
# Create route.ts with GET and POST handlers
cat > "$ROUTE_DIR/route.ts" << 'EOF'
import { NextResponse } from "next/server"
/**
* GET /api/ROUTE_NAME
* List all items
*/
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url)
const limit = searchParams.get("limit") || "10"
const offset = searchParams.get("offset") || "0"
// TODO: Implement your data fetching logic
// Example: const items = await db.items.findMany({ take: Number(limit), skip: Number(offset) })
const items: unknown[] = []
return NextResponse.json({
items,
pagination: {
limit: Number(limit),
offset: Number(offset),
total: items.length,
},
})
} catch (error) {
console.error("GET error:", error)
return NextResponse.json(
{ error: "Failed to fetch items" },
{ status: 500 }
)
}
}
/**
* POST /api/ROUTE_NAME
* Create a new item
*/
export async function POST(request: Request) {
try {
const body = await request.json()
// TODO: Validate input
// TODO: Implement your creation logic
// Example: const item = await db.items.create({ data: body })
const item = { id: "new-id", ...body, createdAt: new Date().toISOString() }
return NextResponse.json({ item }, { status: 201 })
} catch (error) {
console.error("POST error:", error)
return NextResponse.json(
{ error: "Failed to create item" },
{ status: 500 }
)
}
}
EOF
# Replace ROUTE_NAME placeholder
sed -i.bak "s/ROUTE_NAME/$ROUTE_NAME/g" "$ROUTE_DIR/route.ts" && rm "$ROUTE_DIR/route.ts.bak"
echo "Created: $ROUTE_DIR/route.ts"
# Create [id]/route.ts if dynamic flag is set
if [ "$CREATE_DYNAMIC" = true ]; then
mkdir -p "$ROUTE_DIR/[id]"
cat > "$ROUTE_DIR/[id]/route.ts" << 'EOF'
import { NextResponse } from "next/server"
type Params = {
params: { id: string }
}
/**
* GET /api/ROUTE_NAME/[id]
* Get a single item by ID
*/
export async function GET(request: Request, { params }: Params) {
try {
const { id } = params
// TODO: Implement your data fetching logic
// Example: const item = await db.items.findUnique({ where: { id } })
const item = null
if (!item) {
return NextResponse.json(
{ error: "Item not found" },
{ status: 404 }
)
}
return NextResponse.json({ item })
} catch (error) {
console.error("GET error:", error)
return NextResponse.json(
{ error: "Failed to fetch item" },
{ status: 500 }
)
}
}
/**
* PUT /api/ROUTE_NAME/[id]
* Update an item by ID
*/
export async function PUT(request: Request, { params }: Params) {
try {
const { id } = params
const body = await request.json()
// TODO: Validate input
// TODO: Implement your update logic
// Example: const item = await db.items.update({ where: { id }, data: body })
const item = { id, ...body, updatedAt: new Date().toISOString() }
return NextResponse.json({ item })
} catch (error) {
console.error("PUT error:", error)
return NextResponse.json(
{ error: "Failed to update item" },
{ status: 500 }
)
}
}
/**
* PATCH /api/ROUTE_NAME/[id]
* Partially update an item by ID
*/
export async function PATCH(request: Request, { params }: Params) {
try {
const { id } = params
const body = await request.json()
// TODO: Validate input
// TODO: Implement your partial update logic
const item = { id, ...body, updatedAt: new Date().toISOString() }
return NextResponse.json({ item })
} catch (error) {
console.error("PATCH error:", error)
return NextResponse.json(
{ error: "Failed to update item" },
{ status: 500 }
)
}
}
/**
* DELETE /api/ROUTE_NAME/[id]
* Delete an item by ID
*/
export async function DELETE(request: Request, { params }: Params) {
try {
const { id } = params
// TODO: Implement your deletion logic
// Example: await db.items.delete({ where: { id } })
return NextResponse.json({
id,
deleted: true,
message: "Item deleted successfully",
})
} catch (error) {
console.error("DELETE error:", error)
return NextResponse.json(
{ error: "Failed to delete item" },
{ status: 500 }
)
}
}
EOF
# Replace ROUTE_NAME placeholder
sed -i.bak "s/ROUTE_NAME/$ROUTE_NAME/g" "$ROUTE_DIR/[id]/route.ts" && rm "$ROUTE_DIR/[id]/route.ts.bak"
echo "Created: $ROUTE_DIR/[id]/route.ts"
fi
echo ""
echo "API route '$ROUTE_NAME' created successfully!"
echo "Location: $ROUTE_DIR"
echo ""
echo "Available endpoints:"
echo " GET /api/$ROUTE_NAME"
echo " POST /api/$ROUTE_NAME"
if [ "$CREATE_DYNAMIC" = true ]; then
echo " GET /api/$ROUTE_NAME/:id"
echo " PUT /api/$ROUTE_NAME/:id"
echo " PATCH /api/$ROUTE_NAME/:id"
echo " DELETE /api/$ROUTE_NAME/:id"
fi
echo ""
echo "Next steps:"
echo "1. Implement your data fetching/mutation logic"
echo "2. Add input validation (consider using Zod)"
echo "3. Test your endpoints"
#!/bin/bash
# Create Component Script for Next.js
# Creates a new React component with optional test file
# Usage: ./scripts/create-component.sh <component-name> [--client] [--test] [--dir DIR]
set -e
if [ $# -eq 0 ]; then
echo "Error: Component name required"
echo "Usage: ./scripts/create-component.sh <component-name> [OPTIONS]"
echo ""
echo "Examples:"
echo " ./scripts/create-component.sh Button"
echo " ./scripts/create-component.sh UserCard --client --test"
echo " ./scripts/create-component.sh Header --dir app/components"
exit 1
fi
COMPONENT_NAME=$1
shift
IS_CLIENT=false
CREATE_TEST=false
COMPONENT_DIR="components"
# Parse options
while [[ $# -gt 0 ]]; do
case $1 in
--client|-c)
IS_CLIENT=true
shift
;;
--test|-t)
CREATE_TEST=true
shift
;;
--dir|-d)
COMPONENT_DIR="$2"
shift 2
;;
--help|-h)
echo "Usage: ./scripts/create-component.sh <component-name> [OPTIONS]"
echo ""
echo "Options:"
echo " --client, -c Create as Client Component with 'use client'"
echo " --test, -t Create a test file"
echo " --dir, -d DIR Directory to create component in (default: components)"
echo " --help, -h Show this help message"
exit 0
;;
*)
echo "Unknown option: $1"
exit 1
;;
esac
done
# Convert component name to PascalCase
PASCAL_CASE=$(echo "$COMPONENT_NAME" | sed -r 's/(^|-)([a-z])/\U\2/g')
# Convert to kebab-case for filename
KEBAB_CASE=$(echo "$COMPONENT_NAME" | sed -r 's/([A-Z])/-\L\1/g' | sed 's/^-//')
FULL_DIR="$COMPONENT_DIR"
COMPONENT_FILE="$FULL_DIR/$KEBAB_CASE.tsx"
# Check if component already exists
if [ -f "$COMPONENT_FILE" ]; then
echo "Error: Component already exists at $COMPONENT_FILE"
exit 1
fi
# Create directory
mkdir -p "$FULL_DIR"
# Create component file
if [ "$IS_CLIENT" = true ]; then
cat > "$COMPONENT_FILE" << EOF
"use client"
import { useState } from "react"
interface ${PASCAL_CASE}Props {
className?: string
children?: React.ReactNode
}
export function ${PASCAL_CASE}({ className, children }: ${PASCAL_CASE}Props) {
const [state, setState] = useState<string>("")
return (
<div className={className}>
{children}
</div>
)
}
EOF
else
cat > "$COMPONENT_FILE" << EOF
interface ${PASCAL_CASE}Props {
className?: string
children?: React.ReactNode
}
export function ${PASCAL_CASE}({ className, children }: ${PASCAL_CASE}Props) {
return (
<div className={className}>
{children}
</div>
)
}
EOF
fi
echo "Created: $COMPONENT_FILE"
# Create test file if requested
if [ "$CREATE_TEST" = true ]; then
TEST_FILE="$FULL_DIR/$KEBAB_CASE.test.tsx"
if [ "$IS_CLIENT" = true ]; then
cat > "$TEST_FILE" << EOF
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { ${PASCAL_CASE} } from "./$KEBAB_CASE"
describe("${PASCAL_CASE}", () => {
it("renders children", () => {
render(<${PASCAL_CASE}>Hello</${PASCAL_CASE}>)
expect(screen.getByText("Hello")).toBeInTheDocument()
})
it("applies className", () => {
render(<${PASCAL_CASE} className="custom-class">Content</${PASCAL_CASE}>)
expect(screen.getByText("Content").parentElement).toHaveClass("custom-class")
})
// Add more tests for interactive behavior
})
EOF
else
cat > "$TEST_FILE" << EOF
import { render, screen } from "@testing-library/react"
import { ${PASCAL_CASE} } from "./$KEBAB_CASE"
describe("${PASCAL_CASE}", () => {
it("renders children", () => {
render(<${PASCAL_CASE}>Hello</${PASCAL_CASE}>)
expect(screen.getByText("Hello")).toBeInTheDocument()
})
it("applies className", () => {
render(<${PASCAL_CASE} className="custom-class">Content</${PASCAL_CASE}>)
expect(screen.getByText("Content").parentElement).toHaveClass("custom-class")
})
})
EOF
fi
echo "Created: $TEST_FILE"
fi
echo ""
echo "Component '${PASCAL_CASE}' created successfully!"
echo ""
echo "Import with:"
echo " import { ${PASCAL_CASE} } from \"@/$COMPONENT_DIR/$KEBAB_CASE\""
#!/bin/bash
# Create Page Script for Next.js App Router
# Creates a new page with optional layout, loading, and error files
# Usage: ./scripts/create-page.sh <page-path> [--layout] [--loading] [--error]
set -e
if [ $# -eq 0 ]; then
echo "Error: Page path required"
echo "Usage: ./scripts/create-page.sh <page-path> [OPTIONS]"
echo ""
echo "Examples:"
echo " ./scripts/create-page.sh about"
echo " ./scripts/create-page.sh dashboard --layout --loading"
echo " ./scripts/create-page.sh blog/[slug] --loading --error"
exit 1
fi
PAGE_PATH=$1
shift
CREATE_LAYOUT=false
CREATE_LOADING=false
CREATE_ERROR=false
# Parse options
while [[ $# -gt 0 ]]; do
case $1 in
--layout|-l)
CREATE_LAYOUT=true
shift
;;
--loading)
CREATE_LOADING=true
shift
;;
--error|-e)
CREATE_ERROR=true
shift
;;
--all|-a)
CREATE_LAYOUT=true
CREATE_LOADING=true
CREATE_ERROR=true
shift
;;
--help|-h)
echo "Usage: ./scripts/create-page.sh <page-path> [OPTIONS]"
echo ""
echo "Options:"
echo " --layout, -l Create a layout.tsx file"
echo " --loading Create a loading.tsx file"
echo " --error, -e Create an error.tsx file"
echo " --all, -a Create all optional files"
echo " --help, -h Show this help message"
exit 0
;;
*)
echo "Unknown option: $1"
exit 1
;;
esac
done
PAGE_DIR="app/$PAGE_PATH"
# Check if page already exists
if [ -f "$PAGE_DIR/page.tsx" ]; then
echo "Error: Page already exists at $PAGE_DIR/page.tsx"
exit 1
fi
# Create directory
mkdir -p "$PAGE_DIR"
# Extract page name for component naming
PAGE_NAME=$(basename "$PAGE_PATH" | sed 's/\[//g' | sed 's/\]//g')
COMPONENT_NAME=$(echo "$PAGE_NAME" | sed -r 's/(^|-)([a-z])/\U\2/g')Page
# Check if it's a dynamic route
IS_DYNAMIC=false
if [[ "$PAGE_PATH" == *"["* ]]; then
IS_DYNAMIC=true
# Extract param name
PARAM_NAME=$(echo "$PAGE_NAME" | sed 's/\.\.\.//g')
fi
# Create page.tsx
if [ "$IS_DYNAMIC" = true ]; then
if [[ "$PAGE_PATH" == *"[..."* ]]; then
# Catch-all route
cat > "$PAGE_DIR/page.tsx" << EOF
export default async function ${COMPONENT_NAME}({
params,
}: {
params: { ${PARAM_NAME}: string[] }
}) {
const path = params.${PARAM_NAME}.join("/")
return (
<div>
<h1>${COMPONENT_NAME}</h1>
<p>Path: {path}</p>
</div>
)
}
EOF
else
# Single dynamic param
cat > "$PAGE_DIR/page.tsx" << EOF
export default async function ${COMPONENT_NAME}({
params,
}: {
params: { ${PARAM_NAME}: string }
}) {
return (
<div>
<h1>${COMPONENT_NAME}</h1>
<p>${PARAM_NAME}: {params.${PARAM_NAME}}</p>
</div>
)
}
EOF
fi
else
# Static route
cat > "$PAGE_DIR/page.tsx" << EOF
export default function ${COMPONENT_NAME}() {
return (
<div>
<h1>${COMPONENT_NAME}</h1>
<p>Welcome to the ${PAGE_NAME} page</p>
</div>
)
}
EOF
fi
echo "Created: $PAGE_DIR/page.tsx"
# Create layout.tsx if requested
if [ "$CREATE_LAYOUT" = true ]; then
LAYOUT_NAME=$(echo "$PAGE_NAME" | sed -r 's/(^|-)([a-z])/\U\2/g')Layout
cat > "$PAGE_DIR/layout.tsx" << EOF
export default function ${LAYOUT_NAME}({
children,
}: {
children: React.ReactNode
}) {
return (
<section>
{/* Add shared UI here */}
{children}
</section>
)
}
EOF
echo "Created: $PAGE_DIR/layout.tsx"
fi
# Create loading.tsx if requested
if [ "$CREATE_LOADING" = true ]; then
cat > "$PAGE_DIR/loading.tsx" << EOF
export default function Loading() {
return (
<div className="flex items-center justify-center min-h-[200px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900" />
</div>
)
}
EOF
echo "Created: $PAGE_DIR/loading.tsx"
fi
# Create error.tsx if requested
if [ "$CREATE_ERROR" = true ]; then
cat > "$PAGE_DIR/error.tsx" << EOF
"use client"
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="flex flex-col items-center justify-center min-h-[200px] gap-4">
<h2 className="text-xl font-semibold">Something went wrong!</h2>
<p className="text-gray-600">{error.message}</p>
<button
onClick={() => reset()}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Try again
</button>
</div>
)
}
EOF
echo "Created: $PAGE_DIR/error.tsx"
fi
echo ""
echo "Page '$PAGE_PATH' created successfully!"
echo "Location: $PAGE_DIR"
echo ""
echo "Route: /$PAGE_PATH"
#!/bin/bash
# Development Server Script for Next.js
# Starts the Next.js application in development mode with hot reloading
# Usage: ./scripts/dev-server.sh [--port PORT] [--turbo]
set -e
PORT=3000
USE_TURBO=""
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
--port|-p)
PORT="$2"
shift 2
;;
--turbo|-t)
USE_TURBO="--turbo"
shift
;;
--help|-h)
echo "Usage: ./scripts/dev-server.sh [OPTIONS]"
echo ""
echo "Options:"
echo " --port, -p PORT Port to run the server on (default: 3000)"
echo " --turbo, -t Enable Turbopack for faster development"
echo " --help, -h Show this help message"
exit 0
;;
*)
echo "Unknown option: $1"
echo "Use --help for usage information"
exit 1
;;
esac
done
echo "Starting Next.js development server..."
echo "Port: $PORT"
if [ -n "$USE_TURBO" ]; then
echo "Turbopack: enabled"
fi
echo ""
echo "The server will watch for file changes and auto-refresh"
echo "Press Ctrl+C to stop the server"
echo ""
npx next dev --port "$PORT" $USE_TURBO
#!/bin/bash
# Test Runner Script for Next.js
# Runs Jest tests with various options
# Usage: ./scripts/run-tests.sh [--watch] [--coverage] [--file FILE]
set -e
WATCH=""
COVERAGE=""
FILE=""
UPDATE_SNAPSHOTS=""
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
--watch|-w)
WATCH="--watch"
shift
;;
--coverage|-c)
COVERAGE="--coverage"
shift
;;
--file|-f)
FILE="$2"
shift 2
;;
--update|-u)
UPDATE_SNAPSHOTS="--updateSnapshot"
shift
;;
--ci)
COVERAGE="--coverage"
CI="--ci"
shift
;;
--help|-h)
echo "Usage: ./scripts/run-tests.sh [OPTIONS]"
echo ""
echo "Options:"
echo " --watch, -w Run tests in watch mode"
echo " --coverage, -c Generate coverage report"
echo " --file, -f FILE Run tests for specific file or pattern"
echo " --update, -u Update Jest snapshots"
echo " --ci Run in CI mode (coverage + no watch)"
echo " --help, -h Show this help message"
echo ""
echo "Examples:"
echo " ./scripts/run-tests.sh --watch"
echo " ./scripts/run-tests.sh --coverage"
echo " ./scripts/run-tests.sh --file components/button"
exit 0
;;
*)
# Treat unknown args as test file patterns
FILE="$1"
shift
;;
esac
done
echo "Running Next.js tests..."
echo ""
# Build the command
CMD="npx jest"
if [ -n "$FILE" ]; then
CMD="$CMD $FILE"
fi
if [ -n "$WATCH" ]; then
CMD="$CMD $WATCH"
fi
if [ -n "$COVERAGE" ]; then
CMD="$CMD $COVERAGE"
fi
if [ -n "$UPDATE_SNAPSHOTS" ]; then
CMD="$CMD $UPDATE_SNAPSHOTS"
fi
if [ -n "$CI" ]; then
CMD="$CMD $CI"
fi
echo "Executing: $CMD"
echo ""
$CMD
#!/bin/bash
# Testing Setup Script for Next.js
# Sets up Jest and React Testing Library for a Next.js project
# Usage: ./scripts/setup-testing.sh [--playwright]
set -e
SETUP_PLAYWRIGHT=false
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
--playwright|-p)
SETUP_PLAYWRIGHT=true
shift
;;
--help|-h)
echo "Usage: ./scripts/setup-testing.sh [OPTIONS]"
echo ""
echo "Options:"
echo " --playwright, -p Also set up Playwright for E2E testing"
echo " --help, -h Show this help message"
exit 0
;;
*)
echo "Unknown option: $1"
exit 1
;;
esac
done
echo "Setting up testing for Next.js..."
echo ""
# Install Jest and React Testing Library
echo "Step 1: Installing Jest and React Testing Library..."
npm install --save-dev jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event @types/jest
echo ""
echo "Step 2: Creating Jest configuration..."
# Create jest.config.js
cat > jest.config.js << 'EOF'
const nextJest = require("next/jest")
const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files
dir: "./",
})
// Add any custom config to be passed to Jest
const customJestConfig = {
setupFilesAfterEnv: ["<rootDir>/jest.setup.js"],
testEnvironment: "jest-environment-jsdom",
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/$1",
},
testPathIgnorePatterns: ["<rootDir>/node_modules/", "<rootDir>/e2e/"],
collectCoverageFrom: [
"**/*.{js,jsx,ts,tsx}",
"!**/*.d.ts",
"!**/node_modules/**",
"!**/.next/**",
"!**/coverage/**",
"!jest.config.js",
"!next.config.js",
],
}
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
module.exports = createJestConfig(customJestConfig)
EOF
echo "Created: jest.config.js"
# Create jest.setup.js
cat > jest.setup.js << 'EOF'
import "@testing-library/jest-dom"
// Mock next/navigation
jest.mock("next/navigation", () => ({
useRouter: () => ({
push: jest.fn(),
replace: jest.fn(),
back: jest.fn(),
forward: jest.fn(),
refresh: jest.fn(),
prefetch: jest.fn(),
}),
usePathname: () => "/",
useSearchParams: () => new URLSearchParams(),
useParams: () => ({}),
}))
EOF
echo "Created: jest.setup.js"
# Add test script to package.json if not exists
echo ""
echo "Step 3: Updating package.json scripts..."
# Check if jq is available
if command -v jq &> /dev/null; then
# Add scripts using jq
jq '.scripts.test = "jest" | .scripts["test:watch"] = "jest --watch" | .scripts["test:coverage"] = "jest --coverage"' package.json > package.json.tmp && mv package.json.tmp package.json
echo "Added test scripts to package.json"
else
echo "Note: jq not found. Please add these scripts to package.json manually:"
echo ' "test": "jest"'
echo ' "test:watch": "jest --watch"'
echo ' "test:coverage": "jest --coverage"'
fi
# Create example test file
echo ""
echo "Step 4: Creating example test file..."
mkdir -p __tests__
cat > __tests__/example.test.tsx << 'EOF'
import { render, screen } from "@testing-library/react"
// Example component for testing
function Greeting({ name }: { name: string }) {
return <h1>Hello, {name}!</h1>
}
describe("Example Test Suite", () => {
it("renders a greeting", () => {
render(<Greeting name="World" />)
expect(screen.getByRole("heading")).toHaveTextContent("Hello, World!")
})
})
EOF
echo "Created: __tests__/example.test.tsx"
# Set up Playwright if requested
if [ "$SETUP_PLAYWRIGHT" = true ]; then
echo ""
echo "Step 5: Setting up Playwright for E2E testing..."
npm install --save-dev @playwright/test
# Create playwright.config.ts
cat > playwright.config.ts << 'EOF'
import { defineConfig, devices } from "@playwright/test"
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
],
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
})
EOF
echo "Created: playwright.config.ts"
# Create e2e directory and example test
mkdir -p e2e
cat > e2e/example.spec.ts << 'EOF'
import { test, expect } from "@playwright/test"
test.describe("Home Page", () => {
test("has title", async ({ page }) => {
await page.goto("/")
// Update this to match your actual page title
await expect(page).toHaveTitle(/Next/)
})
test("navigates correctly", async ({ page }) => {
await page.goto("/")
// Add your navigation tests here
})
})
EOF
echo "Created: e2e/example.spec.ts"
# Install Playwright browsers
echo ""
echo "Installing Playwright browsers..."
npx playwright install
if command -v jq &> /dev/null; then
jq '.scripts["test:e2e"] = "playwright test" | .scripts["test:e2e:ui"] = "playwright test --ui"' package.json > package.json.tmp && mv package.json.tmp package.json
echo "Added E2E test scripts to package.json"
else
echo "Note: Add these scripts to package.json:"
echo ' "test:e2e": "playwright test"'
echo ' "test:e2e:ui": "playwright test --ui"'
fi
fi
echo ""
echo "Testing setup complete!"
echo ""
echo "Available commands:"
echo " npm test - Run all tests"
echo " npm run test:watch - Run tests in watch mode"
echo " npm run test:coverage - Run tests with coverage"
if [ "$SETUP_PLAYWRIGHT" = true ]; then
echo " npm run test:e2e - Run E2E tests"
echo " npm run test:e2e:ui - Run E2E tests with UI"
fi
echo ""
echo "Get started by running: npm test"
/**
* API Route Handler Template
*
* This template demonstrates a complete API route with:
* - All HTTP methods (GET, POST, PUT, PATCH, DELETE)
* - Request validation with Zod
* - Error handling
* - Authentication checks
* - CORS headers
* - Pagination
* - Caching options
*
* Usage:
* 1. Copy to app/api/[route]/route.ts
* 2. Replace ENTITY with your resource name
* 3. Implement your data access logic
*
* Location: app/api/[route-name]/route.ts
*/
import { NextRequest, NextResponse } from "next/server"
import { z } from "zod"
// ============================================================================
// TYPES & SCHEMAS
// ============================================================================
// Validation schemas using Zod
const CreateEntitySchema = z.object({
title: z.string().min(1, "Title is required").max(200),
description: z.string().optional(),
status: z.enum(["draft", "published", "archived"]).default("draft"),
tags: z.array(z.string()).optional(),
metadata: z.record(z.unknown()).optional(),
})
const UpdateEntitySchema = CreateEntitySchema.partial()
const QueryParamsSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
search: z.string().optional(),
status: z.enum(["draft", "published", "archived"]).optional(),
sortBy: z.enum(["createdAt", "updatedAt", "title"]).default("createdAt"),
sortOrder: z.enum(["asc", "desc"]).default("desc"),
})
type CreateEntityInput = z.infer<typeof CreateEntitySchema>
type UpdateEntityInput = z.infer<typeof UpdateEntitySchema>
type QueryParams = z.infer<typeof QueryParamsSchema>
interface Entity {
id: string
title: string
description?: string
status: "draft" | "published" | "archived"
tags: string[]
metadata: Record<string, unknown>
createdAt: string
updatedAt: string
}
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
/**
* Parse and validate query parameters
*/
function parseQueryParams(searchParams: URLSearchParams): QueryParams {
const params: Record<string, string> = {}
searchParams.forEach((value, key) => {
params[key] = value
})
return QueryParamsSchema.parse(params)
}
/**
* Create standardized API response
*/
function createResponse<T>(
data: T,
options: {
status?: number
headers?: Record<string, string>
} = {}
) {
const { status = 200, headers = {} } = options
return NextResponse.json(data, {
status,
headers: {
"Content-Type": "application/json",
...headers,
},
})
}
/**
* Create error response
*/
function createErrorResponse(
message: string,
options: {
status?: number
code?: string
details?: unknown
} = {}
) {
const { status = 500, code = "INTERNAL_ERROR", details } = options
return NextResponse.json(
{
error: {
message,
code,
...(details && { details }),
},
},
{ status }
)
}
/**
* Check authentication (implement your auth logic)
*/
async function checkAuth(request: NextRequest): Promise<{ userId: string } | null> {
const authHeader = request.headers.get("authorization")
if (!authHeader?.startsWith("Bearer ")) {
return null
}
const token = authHeader.slice(7)
// TODO: Implement your token verification logic
// Example: return await verifyToken(token)
// Placeholder: return mock user
return { userId: "user_123" }
}
// ============================================================================
// DATA ACCESS (Replace with your implementation)
// ============================================================================
// Simulated database operations - replace with your actual data access
async function findEntities(params: QueryParams): Promise<{ items: Entity[]; total: number }> {
// TODO: Implement actual database query
return { items: [], total: 0 }
}
async function findEntityById(id: string): Promise<Entity | null> {
// TODO: Implement actual database query
return null
}
async function createEntity(data: CreateEntityInput): Promise<Entity> {
// TODO: Implement actual database insert
const now = new Date().toISOString()
return {
id: crypto.randomUUID(),
...data,
description: data.description ?? undefined,
tags: data.tags ?? [],
metadata: data.metadata ?? {},
createdAt: now,
updatedAt: now,
}
}
async function updateEntity(id: string, data: UpdateEntityInput): Promise<Entity | null> {
// TODO: Implement actual database update
return null
}
async function deleteEntity(id: string): Promise<boolean> {
// TODO: Implement actual database delete
return true
}
// ============================================================================
// HTTP HANDLERS
// ============================================================================
/**
* GET /api/entities
* List entities with pagination and filtering
*/
export async function GET(request: NextRequest) {
try {
// Parse query parameters
const params = parseQueryParams(request.nextUrl.searchParams)
// Fetch data
const { items, total } = await findEntities(params)
// Calculate pagination metadata
const totalPages = Math.ceil(total / params.limit)
const hasNextPage = params.page < totalPages
const hasPrevPage = params.page > 1
return createResponse({
data: items,
pagination: {
page: params.page,
limit: params.limit,
total,
totalPages,
hasNextPage,
hasPrevPage,
},
}, {
headers: {
// Cache for 60 seconds, allow stale for 300 seconds while revalidating
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=300",
},
})
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse("Invalid query parameters", {
status: 400,
code: "VALIDATION_ERROR",
details: error.errors,
})
}
console.error("GET /api/entities error:", error)
return createErrorResponse("Failed to fetch entities")
}
}
/**
* POST /api/entities
* Create a new entity
*/
export async function POST(request: NextRequest) {
try {
// Check authentication
const auth = await checkAuth(request)
if (!auth) {
return createErrorResponse("Unauthorized", {
status: 401,
code: "UNAUTHORIZED",
})
}
// Parse and validate request body
const body = await request.json()
const data = CreateEntitySchema.parse(body)
// Create entity
const entity = await createEntity(data)
return createResponse(
{ data: entity },
{ status: 201 }
)
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse("Validation failed", {
status: 400,
code: "VALIDATION_ERROR",
details: error.errors,
})
}
if (error instanceof SyntaxError) {
return createErrorResponse("Invalid JSON body", {
status: 400,
code: "INVALID_JSON",
})
}
console.error("POST /api/entities error:", error)
return createErrorResponse("Failed to create entity")
}
}
// ============================================================================
// DYNAMIC ROUTE HANDLERS
// ============================================================================
// For app/api/entities/[id]/route.ts
/**
* GET /api/entities/[id]
* Get a single entity by ID
*/
export async function GET_BY_ID(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const entity = await findEntityById(params.id)
if (!entity) {
return createErrorResponse("Entity not found", {
status: 404,
code: "NOT_FOUND",
})
}
return createResponse({ data: entity })
} catch (error) {
console.error(`GET /api/entities/${params.id} error:`, error)
return createErrorResponse("Failed to fetch entity")
}
}
/**
* PUT /api/entities/[id]
* Update an entity (full replacement)
*/
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
// Check authentication
const auth = await checkAuth(request)
if (!auth) {
return createErrorResponse("Unauthorized", {
status: 401,
code: "UNAUTHORIZED",
})
}
// Parse and validate request body
const body = await request.json()
const data = CreateEntitySchema.parse(body)
// Update entity
const entity = await updateEntity(params.id, data)
if (!entity) {
return createErrorResponse("Entity not found", {
status: 404,
code: "NOT_FOUND",
})
}
return createResponse({ data: entity })
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse("Validation failed", {
status: 400,
code: "VALIDATION_ERROR",
details: error.errors,
})
}
console.error(`PUT /api/entities/${params.id} error:`, error)
return createErrorResponse("Failed to update entity")
}
}
/**
* PATCH /api/entities/[id]
* Partially update an entity
*/
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
// Check authentication
const auth = await checkAuth(request)
if (!auth) {
return createErrorResponse("Unauthorized", {
status: 401,
code: "UNAUTHORIZED",
})
}
// Parse and validate request body
const body = await request.json()
const data = UpdateEntitySchema.parse(body)
// Update entity
const entity = await updateEntity(params.id, data)
if (!entity) {
return createErrorResponse("Entity not found", {
status: 404,
code: "NOT_FOUND",
})
}
return createResponse({ data: entity })
} catch (error) {
if (error instanceof z.ZodError) {
return createErrorResponse("Validation failed", {
status: 400,
code: "VALIDATION_ERROR",
details: error.errors,
})
}
console.error(`PATCH /api/entities/${params.id} error:`, error)
return createErrorResponse("Failed to update entity")
}
}
/**
* DELETE /api/entities/[id]
* Delete an entity
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
// Check authentication
const auth = await checkAuth(request)
if (!auth) {
return createErrorResponse("Unauthorized", {
status: 401,
code: "UNAUTHORIZED",
})
}
const deleted = await deleteEntity(params.id)
if (!deleted) {
return createErrorResponse("Entity not found", {
status: 404,
code: "NOT_FOUND",
})
}
return createResponse({
data: { id: params.id, deleted: true },
})
} catch (error) {
console.error(`DELETE /api/entities/${params.id} error:`, error)
return createErrorResponse("Failed to delete entity")
}
}
// ============================================================================
// CORS HANDLER (for OPTIONS preflight requests)
// ============================================================================
/**
* OPTIONS handler for CORS preflight requests
*/
export async function OPTIONS(request: NextRequest) {
return new NextResponse(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Max-Age": "86400",
},
})
}
// ============================================================================
// ROUTE SEGMENT CONFIG
// ============================================================================
// Force dynamic rendering (no caching at route level)
// export const dynamic = 'force-dynamic'
// Set maximum request duration (in seconds)
// export const maxDuration = 30
/**
* Client Component Template
*
* This template demonstrates a Client Component with:
* - State management (useState, useReducer)
* - Side effects (useEffect)
* - Event handlers
* - Form handling
* - Custom hooks
* - Composition with Server Components
*
* Use Client Components when you need:
* - Interactivity (onClick, onChange, etc.)
* - State (useState, useReducer)
* - Effects (useEffect, useLayoutEffect)
* - Browser APIs (window, localStorage, etc.)
* - Custom hooks that use state/effects
*
* Usage:
* 1. Copy to components/[name].tsx
* 2. Replace COMPONENT_NAME with your component name
* 3. Implement your interactive logic
*
* Location: components/[component-name].tsx
*/
"use client"
import {
useState,
useEffect,
useCallback,
useMemo,
useRef,
useTransition,
forwardRef,
type FormEvent,
type ChangeEvent,
type KeyboardEvent,
} from "react"
import { useRouter, useSearchParams } from "next/navigation"
// ============================================================================
// TYPES
// ============================================================================
interface Item {
id: string
name: string
completed: boolean
priority: "low" | "medium" | "high"
}
interface COMPONENT_NAMEProps {
initialItems?: Item[]
onItemsChange?: (items: Item[]) => void
className?: string
disabled?: boolean
}
type FilterType = "all" | "active" | "completed"
type SortType = "name" | "priority" | "none"
// ============================================================================
// CUSTOM HOOKS
// ============================================================================
/**
* Custom hook for managing local storage
*/
function useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(() => {
if (typeof window === "undefined") {
return initialValue
}
try {
const item = window.localStorage.getItem(key)
return item ? JSON.parse(item) : initialValue
} catch (error) {
console.error(`Error reading localStorage key "${key}":`, error)
return initialValue
}
})
const setValue = useCallback(
(value: T | ((val: T) => T)) => {
try {
const valueToStore =
value instanceof Function ? value(storedValue) : value
setStoredValue(valueToStore)
if (typeof window !== "undefined") {
window.localStorage.setItem(key, JSON.stringify(valueToStore))
}
} catch (error) {
console.error(`Error setting localStorage key "${key}":`, error)
}
},
[key, storedValue]
)
return [storedValue, setValue] as const
}
/**
* Custom hook for debounced value
*/
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay)
return () => clearTimeout(timer)
}, [value, delay])
return debouncedValue
}
/**
* Custom hook for keyboard shortcuts
*/
function useKeyboardShortcut(
key: string,
callback: () => void,
modifiers: { ctrl?: boolean; shift?: boolean; alt?: boolean } = {}
) {
useEffect(() => {
function handleKeyDown(event: globalThis.KeyboardEvent) {
const matchesKey = event.key.toLowerCase() === key.toLowerCase()
const matchesCtrl = modifiers.ctrl ? event.ctrlKey || event.metaKey : true
const matchesShift = modifiers.shift ? event.shiftKey : true
const matchesAlt = modifiers.alt ? event.altKey : true
if (matchesKey && matchesCtrl && matchesShift && matchesAlt) {
event.preventDefault()
callback()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [key, callback, modifiers])
}
// ============================================================================
// SUB-COMPONENTS
// ============================================================================
/**
* Input component for adding items
*/
const ItemInput = forwardRef<
HTMLInputElement,
{
value: string
onChange: (value: string) => void
onSubmit: () => void
disabled?: boolean
placeholder?: string
}
>(function ItemInput({ value, onChange, onSubmit, disabled, placeholder }, ref) {
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
onSubmit()
}
}
return (
<input
ref={ref}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
disabled={disabled}
placeholder={placeholder || "Add new item..."}
className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100"
/>
)
})
/**
* Priority select component
*/
function PrioritySelect({
value,
onChange,
}: {
value: Item["priority"]
onChange: (priority: Item["priority"]) => void
}) {
const priorities: { value: Item["priority"]; label: string; color: string }[] = [
{ value: "low", label: "Low", color: "bg-green-100 text-green-800" },
{ value: "medium", label: "Medium", color: "bg-yellow-100 text-yellow-800" },
{ value: "high", label: "High", color: "bg-red-100 text-red-800" },
]
return (
<select
value={value}
onChange={(e) => onChange(e.target.value as Item["priority"])}
className="px-2 py-1 text-sm border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{priorities.map((p) => (
<option key={p.value} value={p.value}>
{p.label}
</option>
))}
</select>
)
}
/**
* Single item component
*/
function ItemRow({
item,
onToggle,
onDelete,
onPriorityChange,
}: {
item: Item
onToggle: () => void
onDelete: () => void
onPriorityChange: (priority: Item["priority"]) => void
}) {
const priorityColors = {
low: "border-l-green-500",
medium: "border-l-yellow-500",
high: "border-l-red-500",
}
return (
<div
className={`flex items-center gap-3 p-3 bg-white border rounded-lg border-l-4 ${priorityColors[item.priority]} hover:shadow-sm transition-shadow`}
>
<input
type="checkbox"
checked={item.completed}
onChange={onToggle}
className="w-5 h-5 rounded border-gray-300"
/>
<span
className={`flex-1 ${item.completed ? "line-through text-gray-400" : ""}`}
>
{item.name}
</span>
<PrioritySelect value={item.priority} onChange={onPriorityChange} />
<button
onClick={onDelete}
className="p-1 text-gray-400 hover:text-red-500 transition-colors"
aria-label="Delete item"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
)
}
/**
* Filter tabs component
*/
function FilterTabs({
filter,
onFilterChange,
counts,
}: {
filter: FilterType
onFilterChange: (filter: FilterType) => void
counts: Record<FilterType, number>
}) {
const tabs: { key: FilterType; label: string }[] = [
{ key: "all", label: "All" },
{ key: "active", label: "Active" },
{ key: "completed", label: "Completed" },
]
return (
<div className="flex gap-1 p-1 bg-gray-100 rounded-lg">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => onFilterChange(tab.key)}
className={`flex-1 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
filter === tab.key
? "bg-white shadow-sm"
: "text-gray-600 hover:text-gray-900"
}`}
>
{tab.label} ({counts[tab.key]})
</button>
))}
</div>
)
}
// ============================================================================
// MAIN COMPONENT
// ============================================================================
/**
* Interactive item list component (Client Component)
*
* Features:
* - Add, toggle, delete items
* - Filter by status
* - Sort by priority or name
* - Search/filter items
* - Persist to localStorage
* - Keyboard shortcuts
*/
export function COMPONENT_NAME({
initialItems = [],
onItemsChange,
className = "",
disabled = false,
}: COMPONENT_NAMEProps) {
// State
const [items, setItems] = useLocalStorage<Item[]>("items", initialItems)
const [newItemName, setNewItemName] = useState("")
const [filter, setFilter] = useState<FilterType>("all")
const [sortBy, setSortBy] = useState<SortType>("none")
const [searchQuery, setSearchQuery] = useState("")
const [isPending, startTransition] = useTransition()
// Refs
const inputRef = useRef<HTMLInputElement>(null)
// Hooks
const router = useRouter()
const searchParams = useSearchParams()
const debouncedSearch = useDebounce(searchQuery, 300)
// Keyboard shortcut: Ctrl+N to focus input
useKeyboardShortcut("n", () => inputRef.current?.focus(), { ctrl: true })
// Sync filter with URL
useEffect(() => {
const urlFilter = searchParams.get("filter") as FilterType | null
if (urlFilter && ["all", "active", "completed"].includes(urlFilter)) {
setFilter(urlFilter)
}
}, [searchParams])
// Notify parent of changes
useEffect(() => {
onItemsChange?.(items)
}, [items, onItemsChange])
// Computed values
const counts = useMemo(
() => ({
all: items.length,
active: items.filter((i) => !i.completed).length,
completed: items.filter((i) => i.completed).length,
}),
[items]
)
const filteredItems = useMemo(() => {
let result = items
// Apply status filter
if (filter === "active") {
result = result.filter((i) => !i.completed)
} else if (filter === "completed") {
result = result.filter((i) => i.completed)
}
// Apply search filter
if (debouncedSearch) {
const query = debouncedSearch.toLowerCase()
result = result.filter((i) => i.name.toLowerCase().includes(query))
}
// Apply sorting
if (sortBy === "name") {
result = [...result].sort((a, b) => a.name.localeCompare(b.name))
} else if (sortBy === "priority") {
const priorityOrder = { high: 0, medium: 1, low: 2 }
result = [...result].sort(
(a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]
)
}
return result
}, [items, filter, debouncedSearch, sortBy])
// Handlers
const handleAddItem = useCallback(() => {
if (!newItemName.trim() || disabled) return
const newItem: Item = {
id: crypto.randomUUID(),
name: newItemName.trim(),
completed: false,
priority: "medium",
}
setItems((prev) => [...prev, newItem])
setNewItemName("")
inputRef.current?.focus()
}, [newItemName, disabled, setItems])
const handleToggleItem = useCallback(
(id: string) => {
setItems((prev) =>
prev.map((item) =>
item.id === id ? { ...item, completed: !item.completed } : item
)
)
},
[setItems]
)
const handleDeleteItem = useCallback(
(id: string) => {
setItems((prev) => prev.filter((item) => item.id !== id))
},
[setItems]
)
const handlePriorityChange = useCallback(
(id: string, priority: Item["priority"]) => {
setItems((prev) =>
prev.map((item) => (item.id === id ? { ...item, priority } : item))
)
},
[setItems]
)
const handleFilterChange = useCallback(
(newFilter: FilterType) => {
setFilter(newFilter)
startTransition(() => {
const params = new URLSearchParams(searchParams.toString())
params.set("filter", newFilter)
router.push(`?${params.toString()}`, { scroll: false })
})
},
[router, searchParams]
)
const handleClearCompleted = useCallback(() => {
setItems((prev) => prev.filter((item) => !item.completed))
}, [setItems])
return (
<div className={`space-y-4 ${className}`}>
{/* Header */}
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">Items</h2>
<div className="flex items-center gap-2">
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortType)}
className="px-2 py-1 text-sm border rounded"
>
<option value="none">No sorting</option>
<option value="name">Sort by name</option>
<option value="priority">Sort by priority</option>
</select>
</div>
</div>
{/* Search */}
<input
type="search"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search items..."
className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{/* Add Item */}
<div className="flex gap-2">
<ItemInput
ref={inputRef}
value={newItemName}
onChange={setNewItemName}
onSubmit={handleAddItem}
disabled={disabled}
/>
<button
onClick={handleAddItem}
disabled={disabled || !newItemName.trim()}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
Add
</button>
</div>
{/* Filters */}
<FilterTabs
filter={filter}
onFilterChange={handleFilterChange}
counts={counts}
/>
{/* Item List */}
<div className="space-y-2">
{filteredItems.length === 0 ? (
<p className="text-center py-8 text-gray-500">
{searchQuery
? "No items match your search"
: filter === "completed"
? "No completed items"
: filter === "active"
? "No active items"
: "No items yet. Add one above!"}
</p>
) : (
filteredItems.map((item) => (
<ItemRow
key={item.id}
item={item}
onToggle={() => handleToggleItem(item.id)}
onDelete={() => handleDeleteItem(item.id)}
onPriorityChange={(priority) =>
handlePriorityChange(item.id, priority)
}
/>
))
)}
</div>
{/* Footer */}
{counts.completed > 0 && (
<div className="flex justify-between items-center pt-4 border-t">
<span className="text-sm text-gray-500">
{counts.completed} completed item{counts.completed !== 1 ? "s" : ""}
</span>
<button
onClick={handleClearCompleted}
className="text-sm text-red-500 hover:text-red-700"
>
Clear completed
</button>
</div>
)}
{/* Pending indicator */}
{isPending && (
<div className="fixed bottom-4 right-4 bg-blue-500 text-white px-4 py-2 rounded-lg shadow-lg">
Updating...
</div>
)}
</div>
)
}
// Export as default
export default COMPONENT_NAME