
Nextjs Developer
- 4.2k installs
- 10.8k repo stars
- Updated May 20, 2026
- jeffallan/claude-skills
How to architect, implement, and deploy production Next.js 14+ applications with App Router, server components, and optimized performance.
About
Next.js Developer is a specialist skill for building modern Next.js 14+ applications using App Router and React Server Components. Developers invoke it to architect file-based routing with layouts, implement server-side data fetching with caching and ISR, create server actions for form handling and mutations, optimize images and fonts, add loading and error boundaries, and configure production deployments. Core workflows span route structure planning, streaming SSR setup, SEO metadata generation via generateMetadata, performance optimization using next/image and next/font, and Vercel deployment validation. The skill enforces best practices: prefer server components by default, use native fetch with explicit cache directives, avoid Pages Router, and always run next build before deployment. App Router file-based routing with layouts, templates, and route groups Server Components and Server Actions for full-stack mutations and revalidation Data fetching
- App Router file-based routing with layouts, templates, and route groups
- Server Components and Server Actions for full-stack mutations and revalidation
- Data fetching with native fetch, ISR (60s revalidate), and cache directives
- SEO via generateMetadata with dynamic params and OpenGraph support
- Image/font optimization, loading.tsx/error.tsx boundaries, and Vercel deployment
Nextjs Developer by the numbers
- 4,217 all-time installs (skills.sh)
- +134 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #113 of 2,277 Frontend Development skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
nextjs-developer capabilities & compatibility
- Capabilities
- app router architecture planning and scaffold ge · server component and server action implementatio · data fetching with caching and isr configuration · seo metadata generation via generatemetadata · performance optimization (images, fonts, bundle) · error and loading boundary setup · vercel deployment validation and configuration
- Works with
- vercel
- Use cases
- frontend · api development · seo · web design
- Platforms
- macOS · Windows · Linux
- Runs
- Hosted SaaS
- Pricing
- Free
npx skills add https://github.com/jeffallan/claude-skills --skill nextjs-developerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 4.2k |
|---|---|
| repo stars | ★ 10.8k |
| Security audit | 3 / 3 scanners passed |
| Last updated | May 20, 2026 |
| Repository | jeffallan/claude-skills ↗ |
What it does
Build production Next.js 14+ applications with App Router, server components, server actions, and optimized deployment to Vercel.
Who is it for?
Building full-stack web applications with Next.js 14+, implementing server-driven architectures, optimizing Core Web Vitals, and deploying to Vercel.
Skip if: Pages Router applications, legacy Next.js versions pre-13, static site generation without interactivity, or non-Vercel deployments requiring custom Docker setup.
When should I use this skill?
Working with Next.js 14, App Router, RSC, use server, Server Components, Server Actions, React Server Components, generateMetadata, loading.tsx, Next.js deployment, Vercel, Next.js performance.
What you get
Fully functional Next.js application with App Router structure, server components, server actions, proper caching, SEO optimization, and production-ready Vercel deployment.
- App Router file structure
- Layout and page components with server-side data fetching
- Server actions for mutations
By the numbers
- Next.js 14 is the target version with App Router as primary routing mechanism
- Supports ISR revalidation with configurable intervals (e.g., 60 seconds)
- Core Web Vitals benchmark is > 90 on Lighthouse
Files
Next.js Developer
Senior Next.js developer with expertise in Next.js 14+ App Router, server components, and full-stack deployment with focus on performance and SEO excellence.
Core Workflow
1. Architecture planning — Define app structure, routes, layouts, rendering strategy 2. Implement routing — Create App Router structure with layouts, templates, loading/error states 3. Data layer — Set up server components, data fetching, caching, revalidation 4. Optimize — Images, fonts, bundles, streaming, edge runtime 5. Deploy — Production build, environment setup, monitoring
- Validate: run
next buildlocally, confirm zero type errors, checkNEXT_PUBLIC_*and server-only env vars are set, run Lighthouse/PageSpeed to confirm Core Web Vitals > 90
Reference Guide
Load detailed guidance based on context:
| Topic | Reference | Load When |
|---|---|---|
| App Router | references/app-router.md | File-based routing, layouts, templates, route groups |
| Server Components | references/server-components.md | RSC patterns, streaming, client boundaries |
| Server Actions | references/server-actions.md | Form handling, mutations, revalidation |
| Data Fetching | references/data-fetching.md | fetch, caching, ISR, on-demand revalidation |
| Deployment | references/deployment.md | Vercel, self-hosting, Docker, optimization |
Constraints
MUST DO (Next.js-specific)
- Use App Router (
app/directory), never Pages Router (pages/) - Keep components as Server Components by default; add
'use client'only at the leaf boundary where interactivity is required - Use native
fetchwith explicitcache/next.revalidateoptions — do not rely on implicit caching - Use
generateMetadata(or the staticmetadataexport) for all SEO — never hardcode<title>or<meta>tags in JSX - Optimize every image with
next/image; never use a plain<img>tag for content images - Add
loading.tsxanderror.tsxat every route segment that performs async data fetching
MUST NOT DO
- Convert components to Client Components just to access data — fetch server-side first
- Skip
loading.tsx/error.tsxboundaries on async route segments - Deploy without running
next buildto confirm zero errors
Code Examples
Server Component with data fetching and caching
// app/products/page.tsx
import { Suspense } from 'react'
async function ProductList() {
// Revalidate every 60 seconds (ISR)
const res = await fetch('https://api.example.com/products', {
next: { revalidate: 60 },
})
if (!res.ok) throw new Error('Failed to fetch products')
const products: Product[] = await res.json()
return (
<ul>
{products.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
)
}
export default function Page() {
return (
<Suspense fallback={<p>Loading…</p>}>
<ProductList />
</Suspense>
)
}Server Action with form handling and revalidation
// app/products/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
export async function createProduct(formData: FormData) {
const name = formData.get('name') as string
await db.product.create({ data: { name } })
revalidatePath('/products')
}
// app/products/new/page.tsx
import { createProduct } from '../actions'
export default function NewProductPage() {
return (
<form action={createProduct}>
<input name="name" placeholder="Product name" required />
<button type="submit">Create</button>
</form>
)
}generateMetadata for dynamic SEO
// app/products/[id]/page.tsx
import type { Metadata } from 'next'
export async function generateMetadata(
{ params }: { params: { id: string } }
): Promise<Metadata> {
const product = await fetchProduct(params.id)
return {
title: product.name,
description: product.description,
openGraph: { title: product.name, images: [product.imageUrl] },
}
}Output Templates
When implementing Next.js features, provide: 1. App structure (route organization) 2. Layout/page components with proper data fetching 3. Server actions if mutations needed 4. Configuration (next.config.js, TypeScript) 5. Brief explanation of rendering strategy chosen
Knowledge Reference
Next.js 14+, App Router, React Server Components, Server Actions, Streaming SSR, Partial Prerendering, next/image, next/font, Metadata API, Route Handlers, Middleware, Edge Runtime, Turbopack, Vercel deployment
App Router Architecture
File-Based Routing
app/
├── layout.tsx # Root layout (required)
├── page.tsx # Home page (/)
├── loading.tsx # Loading UI
├── error.tsx # Error boundary
├── not-found.tsx # 404 page
├── template.tsx # Re-mounted layout
│
├── (marketing)/ # Route group (no URL segment)
│ ├── layout.tsx
│ ├── about/
│ │ └── page.tsx # /about
│ └── contact/
│ └── page.tsx # /contact
│
├── dashboard/
│ ├── layout.tsx # Shared dashboard layout
│ ├── page.tsx # /dashboard
│ ├── settings/
│ │ └── page.tsx # /dashboard/settings
│ └── @analytics/ # Parallel route (slot)
│ └── page.tsx
│
├── blog/
│ ├── [slug]/
│ │ └── page.tsx # /blog/my-post (dynamic)
│ └── [...slug]/
│ └── page.tsx # /blog/a/b/c (catch-all)
│
└── api/
└── users/
└── route.ts # API route handlerRoot Layout (Required)
// app/layout.tsx
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: {
default: 'My App',
template: '%s | My App'
},
description: 'Next.js 14 application',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className={inter.className}>
{children}
</body>
</html>
)
}Nested Layouts
// app/dashboard/layout.tsx
import { Sidebar } from '@/components/sidebar'
import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await auth()
if (!session) {
redirect('/login')
}
return (
<div className="flex">
<Sidebar />
<main className="flex-1">{children}</main>
</div>
)
}Templates (Re-mount on Navigation)
// app/template.tsx
'use client'
import { useEffect } from 'react'
export default function Template({ children }: { children: React.ReactNode }) {
useEffect(() => {
// Runs on every navigation
console.log('Template mounted')
}, [])
return <div>{children}</div>
}Loading States
// app/dashboard/loading.tsx
export default function Loading() {
return (
<div className="flex items-center justify-center h-screen">
<div className="animate-spin rounded-full h-32 w-32 border-b-2" />
</div>
)
}Error Boundaries
// app/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>
)
}Route Groups
// (marketing) and (shop) share the same URL level
app/
├── (marketing)/
│ ├── layout.tsx # Marketing layout
│ └── about/
│ └── page.tsx # /about
└── (shop)/
├── layout.tsx # Shop layout
└── products/
└── page.tsx # /productsParallel Routes
// app/dashboard/layout.tsx
export default function Layout({
children,
analytics,
team,
}: {
children: React.ReactNode
analytics: React.ReactNode
team: React.ReactNode
}) {
return (
<>
{children}
{analytics}
{team}
</>
)
}
// app/dashboard/@analytics/page.tsx
export default function Analytics() {
return <div>Analytics Dashboard</div>
}Intercepting Routes
// Show modal when navigating from same app
// but show full page on direct navigation
// app/photos/[id]/page.tsx (full page)
export default function PhotoPage({ params }: { params: { id: string } }) {
return <div>Photo {params.id} - Full Page</div>
}
// app/@modal/(.)photos/[id]/page.tsx (modal)
export default function PhotoModal({ params }: { params: { id: string } }) {
return <div>Photo {params.id} - Modal</div>
}Dynamic Routes
// app/blog/[slug]/page.tsx
export default function BlogPost({ params }: { params: { slug: string } }) {
return <h1>Post: {params.slug}</h1>
}
// Generate static params at build time
export async function generateStaticParams() {
const posts = await fetch('https://api.example.com/posts').then(res => res.json())
return posts.map((post: { slug: string }) => ({
slug: post.slug,
}))
}
// Opt out of static generation
export const dynamic = 'force-dynamic'
// Revalidate every 60 seconds
export const revalidate = 60Catch-All Routes
// app/docs/[...slug]/page.tsx
// Matches: /docs/a, /docs/a/b, /docs/a/b/c
export default function Docs({ params }: { params: { slug: string[] } }) {
return <div>Docs: {params.slug.join('/')}</div>
}
// Optional catch-all: [[...slug]]
// Also matches: /docsRoute Handlers (API Routes)
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
const users = await db.user.findMany()
return NextResponse.json(users)
}
export async function POST(request: NextRequest) {
const body = await request.json()
const user = await db.user.create({ data: body })
return NextResponse.json(user, { status: 201 })
}
// Dynamic routes: app/api/users/[id]/route.ts
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const user = await db.user.findUnique({ where: { id: params.id } })
return NextResponse.json(user)
}Metadata API
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
export async function generateMetadata(
{ params }: { params: { slug: string } }
): Promise<Metadata> {
const post = await fetchPost(params.slug)
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [{ url: post.coverImage }],
},
}
}Quick Reference
| File | Purpose | Use Case |
|---|---|---|
layout.tsx | Persistent UI across routes | Shared navigation, auth wrapper |
page.tsx | Route UI | Actual page content |
loading.tsx | Loading fallback | Automatic Suspense boundary |
error.tsx | Error boundary | Handle errors gracefully |
template.tsx | Re-mounted layout | Analytics, animations |
not-found.tsx | 404 page | Custom not found UI |
route.ts | API handler | Backend API endpoints |
Data Fetching & Caching
Extended fetch API
Next.js extends the native fetch with caching and revalidation options:
// app/page.tsx
async function getData() {
const res = await fetch('https://api.example.com/posts', {
cache: 'force-cache', // Default: cache forever (SSG)
})
if (!res.ok) {
throw new Error('Failed to fetch data')
}
return res.json()
}
export default async function Page() {
const data = await getData()
return <div>{/* render data */}</div>
}Cache Options
// 1. Force cache (Static Site Generation)
fetch('https://api.example.com/data', {
cache: 'force-cache' // Default behavior
})
// 2. No cache (Server-Side Rendering)
fetch('https://api.example.com/data', {
cache: 'no-store' // Always fetch fresh data
})
// 3. Revalidate (Incremental Static Regeneration)
fetch('https://api.example.com/data', {
next: { revalidate: 3600 } // Revalidate every hour
})
// 4. Revalidate with tags
fetch('https://api.example.com/data', {
next: { tags: ['posts'] }
})Revalidation Methods
Time-based Revalidation (ISR)
// Revalidate every 60 seconds
async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
next: { revalidate: 60 }
})
return res.json()
}
// Route segment config
export const revalidate = 60 // seconds
export default async function Page() {
const posts = await getPosts()
return <div>{/* render */}</div>
}On-Demand Revalidation
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } 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 })
}
// Usage in Server Action
'use server'
import { revalidatePath } from 'next/cache'
export async function createPost(data: FormData) {
await db.post.create({ data })
// Revalidate specific path
revalidatePath('/posts')
// Revalidate entire layout
revalidatePath('/posts', 'layout')
}Tag-based Revalidation
// Fetch with tags
async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] }
})
return res.json()
}
async function getAuthors() {
const res = await fetch('https://api.example.com/authors', {
next: { tags: ['authors'] }
})
return res.json()
}
// Revalidate by tag
import { revalidateTag } from 'next/cache'
export async function createPost() {
// Revalidate all fetches tagged with 'posts'
revalidateTag('posts')
}Route Segment Config
// app/posts/page.tsx
// Force dynamic rendering
export const dynamic = 'force-dynamic' // 'auto' | 'force-dynamic' | 'error' | 'force-static'
// Revalidation interval
export const revalidate = 3600 // false | 0 | number (seconds)
// Fetch cache
export const fetchCache = 'auto' // 'auto' | 'default-cache' | 'only-cache' | 'force-cache' | 'force-no-store' | 'default-no-store' | 'only-no-store'
// Runtime
export const runtime = 'nodejs' // 'nodejs' | 'edge'
// Preferred region
export const preferredRegion = 'auto' // 'auto' | 'home' | 'edge' | string | string[]
export default async function Page() {
return <div>Posts</div>
}Parallel Data Fetching
async function getUser() {
return fetch('https://api.example.com/user')
}
async function getPosts() {
return fetch('https://api.example.com/posts')
}
async function getComments() {
return fetch('https://api.example.com/comments')
}
export default async function Page() {
// Fetch in parallel with Promise.all
const [user, posts, comments] = await Promise.all([
getUser(),
getPosts(),
getComments(),
])
return (
<div>
<UserInfo user={user} />
<Posts posts={posts} />
<Comments comments={comments} />
</div>
)
}Sequential Data Fetching
// When one fetch depends on another
export default async function Page({ params }: { params: { id: string } }) {
// First fetch
const user = await fetch(`https://api.example.com/users/${params.id}`)
.then(res => res.json())
// Second fetch depends on first
const posts = await fetch(`https://api.example.com/users/${user.id}/posts`)
.then(res => res.json())
return (
<div>
<h1>{user.name}</h1>
<Posts posts={posts} />
</div>
)
}Streaming with Suspense
// app/page.tsx
import { Suspense } from 'react'
async function Posts() {
const posts = await fetch('https://api.example.com/posts', {
cache: 'no-store'
}).then(res => res.json())
return (
<ul>
{posts.map((post: Post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
export default function Page() {
return (
<div>
<h1>Posts</h1>
<Suspense fallback={<div>Loading posts...</div>}>
<Posts />
</Suspense>
</div>
)
}React cache for Deduplication
// lib/data.ts
import { cache } from 'react'
export const getUser = cache(async (id: string) => {
const res = await fetch(`https://api.example.com/users/${id}`)
return res.json()
})
// components/user-profile.tsx
export async function UserProfile({ userId }: { userId: string }) {
const user = await getUser(userId) // Cached
return <div>{user.name}</div>
}
// components/user-posts.tsx
export async function UserPosts({ userId }: { userId: string }) {
const user = await getUser(userId) // Uses cached result
return <div>{user.posts.length} posts</div>
}
// app/page.tsx
export default function Page() {
return (
<>
<UserProfile userId="123" />
<UserPosts userId="123" /> {/* Same fetch, deduplicated */}
</>
)
}Database Queries
// lib/db.ts
import { PrismaClient } from '@prisma/client'
const globalForPrisma = global as unknown as { prisma: PrismaClient }
export const db = globalForPrisma.prisma || new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db
// app/posts/page.tsx
import { db } from '@/lib/db'
export const revalidate = 60 // Revalidate every 60 seconds
export default async function PostsPage() {
const posts = await db.post.findMany({
include: { author: true },
orderBy: { createdAt: 'desc' },
})
return (
<div>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>By {post.author.name}</p>
</article>
))}
</div>
)
}Error Handling
async function getData() {
const res = await fetch('https://api.example.com/data')
if (!res.ok) {
// This will activate the closest error.tsx
throw new Error('Failed to fetch data')
}
return res.json()
}
export default async function Page() {
const data = await getData()
return <div>{data.title}</div>
}
// app/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>
)
}Loading States
// app/posts/loading.tsx
export default function Loading() {
return <div>Loading posts...</div>
}
// app/posts/page.tsx
export default async function PostsPage() {
const posts = await fetch('https://api.example.com/posts')
.then(res => res.json())
return <div>{/* render posts */}</div>
}Client-Side Data Fetching
// When you need client-side fetching
'use client'
import useSWR from 'swr'
const fetcher = (url: string) => fetch(url).then(res => res.json())
export function Posts() {
const { data, error, isLoading } = useSWR('/api/posts', fetcher, {
refreshInterval: 3000, // Refresh every 3 seconds
})
if (error) return <div>Failed to load</div>
if (isLoading) return <div>Loading...</div>
return (
<ul>
{data.map((post: Post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}Preloading Data
// lib/data.ts
import { cache } from 'react'
export const preload = (id: string) => {
void getUser(id) // Trigger fetch without awaiting
}
export const getUser = cache(async (id: string) => {
return fetch(`https://api.example.com/users/${id}`)
.then(res => res.json())
})
// components/user.tsx
import { getUser, preload } from '@/lib/data'
export async function User({ id }: { id: string }) {
const user = await getUser(id)
return <div>{user.name}</div>
}
// app/page.tsx
import { User } from '@/components/user'
import { preload } from '@/lib/data'
export default async function Page() {
preload('123') // Start loading immediately
return <User id="123" />
}Static Generation with Dynamic Routes
// app/posts/[slug]/page.tsx
type Post = {
slug: string
title: string
content: string
}
export async function generateStaticParams() {
const posts = await fetch('https://api.example.com/posts')
.then(res => res.json())
return posts.map((post: Post) => ({
slug: post.slug,
}))
}
export default async function Post({ params }: { params: { slug: string } }) {
const post = await fetch(`https://api.example.com/posts/${params.slug}`)
.then(res => res.json())
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
</article>
)
}Quick Reference
| Strategy | Config | Use Case |
|---|---|---|
| SSG | cache: 'force-cache' | Static content |
| SSR | cache: 'no-store' | Always fresh data |
| ISR | next: { revalidate: 60 } | Periodic updates |
| Tag-based | next: { tags: ['posts'] } | On-demand revalidation |
| Dynamic | export const dynamic = 'force-dynamic' | Per-request data |
Best Practices
1. Default to caching - Use force-cache for static content 2. Use ISR - Revalidate periodically for semi-dynamic content 3. Parallel fetching - Use Promise.all for independent requests 4. Deduplicate - Use React cache() for repeated calls 5. Stream with Suspense - Show content progressively 6. Tag your fetches - Enable granular revalidation 7. Handle errors - Use error.tsx for graceful degradation
Deployment & Production
Vercel Deployment (Recommended)
Quick Deploy
# Install Vercel CLI
npm i -g vercel
# Deploy
vercel
# Production deployment
vercel --prodvercel.json Configuration
{
"buildCommand": "next build",
"devCommand": "next dev",
"installCommand": "npm install",
"framework": "nextjs",
"regions": ["iad1"],
"env": {
"DATABASE_URL": "@database-url",
"NEXT_PUBLIC_API_URL": "https://api.example.com"
},
"headers": [
{
"source": "/api/(.*)",
"headers": [
{ "key": "Access-Control-Allow-Origin", "value": "*" },
{ "key": "Access-Control-Allow-Methods", "value": "GET,POST,PUT,DELETE" }
]
}
],
"redirects": [
{
"source": "/old-blog/:slug",
"destination": "/blog/:slug",
"permanent": true
}
],
"rewrites": [
{
"source": "/api/:path*",
"destination": "https://api.example.com/:path*"
}
]
}Environment Variables
# .env.local (not committed)
DATABASE_URL="postgresql://user:pass@localhost:5432/db"
NEXTAUTH_SECRET="your-secret"
# .env.production (committed, public vars only)
NEXT_PUBLIC_API_URL="https://api.example.com"// Access in Server Components
const dbUrl = process.env.DATABASE_URL
// Access in Client Components (must be prefixed with NEXT_PUBLIC_)
const apiUrl = process.env.NEXT_PUBLIC_API_URLSelf-Hosting
Standalone Output
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
}
module.exports = nextConfig# Build
npm run build
# The standalone folder contains everything needed
# Copy these to your server:
# - .next/standalone/
# - .next/static/
# - public/
# Run on server
node .next/standalone/server.jsNode.js Server
# Build
npm run build
# Start production server
npm start
# With PM2 for process management
pm2 start npm --name "nextjs" -- start
pm2 startup
pm2 saveDocker Deployment
Dockerfile (Multi-stage)
# Stage 1: Dependencies
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# Stage 2: Builder
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED 1
RUN npm run build
# Stage 3: Runner
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
CMD ["node", "server.js"]docker-compose.yml
version: '3.8'
services:
nextjs:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgresql://postgres:postgres@db:5432/myapp
- NEXTAUTH_URL=http://localhost:3000
- NEXTAUTH_SECRET=your-secret
depends_on:
- db
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=myapp
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
volumes:
postgres_data:# Build and run
docker-compose up -d
# View logs
docker-compose logs -f nextjs
# Rebuild
docker-compose up -d --buildProduction Optimization
next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
// Standalone for self-hosting
output: 'standalone',
// Image optimization
images: {
formats: ['image/avif', 'image/webp'],
remotePatterns: [
{
protocol: 'https',
hostname: 'cdn.example.com',
pathname: '/images/**',
},
],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
},
// Compression
compress: true,
// Security headers
async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'X-DNS-Prefetch-Control',
value: 'on'
},
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload'
},
{
key: 'X-Frame-Options',
value: 'SAMEORIGIN'
},
{
key: 'X-Content-Type-Options',
value: 'nosniff'
},
{
key: 'X-XSS-Protection',
value: '1; mode=block'
},
{
key: 'Referrer-Policy',
value: 'origin-when-cross-origin'
},
],
},
]
},
// Experimental features
experimental: {
optimizePackageImports: ['@mui/material', 'lodash'],
},
// Bundle analyzer
webpack: (config, { isServer }) => {
if (process.env.ANALYZE === 'true') {
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer')
config.plugins.push(
new BundleAnalyzerPlugin({
analyzerMode: 'static',
reportFilename: isServer
? '../analyze/server.html'
: './analyze/client.html',
})
)
}
return config
},
}
module.exports = nextConfigBundle Analysis
# Install analyzer
npm install -D @next/bundle-analyzer
# Analyze
ANALYZE=true npm run build
# Or use built-in
npm run build -- --experimental-build-mode=compilePerformance Monitoring
// app/layout.tsx
import { SpeedInsights } from '@vercel/speed-insights/next'
import { Analytics } from '@vercel/analytics/react'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html>
<body>
{children}
<SpeedInsights />
<Analytics />
</body>
</html>
)
}CDN & Edge
Static Asset CDN
// next.config.js
const nextConfig = {
assetPrefix: process.env.NODE_ENV === 'production'
? 'https://cdn.example.com'
: '',
}Edge Runtime
// app/api/edge/route.ts
export const runtime = 'edge'
export async function GET(request: Request) {
return new Response('Hello from Edge!', {
status: 200,
headers: {
'content-type': 'text/plain',
},
})
}
// app/page.tsx
export const runtime = 'edge'
export default async function Page() {
return <div>Edge-rendered page</div>
}Caching Strategy
ISR (Incremental Static Regeneration)
// app/blog/[slug]/page.tsx
export const revalidate = 3600 // Revalidate every hour
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await fetchPost(params.slug)
return <article>{post.content}</article>
}On-Demand Revalidation
// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache'
import { NextRequest } from 'next/server'
export async function POST(request: NextRequest) {
const secret = request.nextUrl.searchParams.get('secret')
if (secret !== process.env.REVALIDATE_SECRET) {
return Response.json({ message: 'Invalid secret' }, { status: 401 })
}
const path = request.nextUrl.searchParams.get('path') || '/'
revalidatePath(path)
return Response.json({ revalidated: true, now: Date.now() })
}Database Connection Pooling
// lib/db.ts
import { PrismaClient } from '@prisma/client'
const globalForPrisma = global as unknown as {
prisma: PrismaClient | undefined
}
export const db =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = dbHealth Check Endpoint
// app/api/health/route.ts
import { db } from '@/lib/db'
export async function GET() {
try {
// Check database connection
await db.$queryRaw`SELECT 1`
return Response.json({
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
})
} catch (error) {
return Response.json(
{
status: 'error',
message: 'Database connection failed',
},
{ status: 503 }
)
}
}CI/CD with GitHub Actions
# .github/workflows/deploy.yml
name: Deploy to Production
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
- name: Build
run: npm run build
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
- name: Deploy to Vercel
uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
vercel-args: '--prod'Monitoring & Logging
// app/error.tsx
'use client'
import * as Sentry from '@sentry/nextjs'
import { useEffect } from 'react'
export default function Error({
error,
}: {
error: Error & { digest?: string }
}) {
useEffect(() => {
Sentry.captureException(error)
}, [error])
return <div>Something went wrong!</div>
}Quick Reference
| Platform | Best For | Effort |
|---|---|---|
| Vercel | Zero-config, optimal performance | Low |
| Netlify | Alternative to Vercel | Low |
| Railway | Simple hosting with databases | Medium |
| AWS/GCP | Enterprise, custom needs | High |
| Docker | Self-hosting, full control | High |
Production Checklist
- [ ] Enable TypeScript strict mode
- [ ] Configure CSP headers
- [ ] Setup error monitoring (Sentry)
- [ ] Configure analytics (Vercel/GA)
- [ ] Optimize images (next/image)
- [ ] Enable compression
- [ ] Setup CDN for static assets
- [ ] Configure database connection pooling
- [ ] Add health check endpoint
- [ ] Setup CI/CD pipeline
- [ ] Configure environment variables
- [ ] Enable ISR/SSG where possible
- [ ] Test Core Web Vitals
- [ ] Setup logging (Datadog/LogRocket)
- [ ] Configure backup strategy
Server Actions
Basic Server Action
// app/actions.ts
'use server'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
const content = formData.get('content') as string
await db.post.create({
data: { title, content }
})
revalidatePath('/posts')
}Form with Server Action
// app/posts/new/page.tsx
import { createPost } from '@/app/actions'
export default function NewPost() {
return (
<form action={createPost}>
<input name="title" required />
<textarea name="content" required />
<button type="submit">Create Post</button>
</form>
)
}Server Action with Validation
// app/actions.ts
'use server'
import { z } from 'zod'
import { revalidatePath } from 'next/cache'
const CreatePostSchema = z.object({
title: z.string().min(3).max(100),
content: z.string().min(10),
})
export async function createPost(formData: FormData) {
const validatedFields = CreatePostSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
})
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
}
}
const { title, content } = validatedFields.data
await db.post.create({
data: { title, content }
})
revalidatePath('/posts')
return { success: true }
}Client Component with Server Action
// components/create-post-form.tsx
'use client'
import { createPost } from '@/app/actions'
import { useFormState, useFormStatus } from 'react-dom'
const initialState = {
errors: {},
}
function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? 'Creating...' : 'Create Post'}
</button>
)
}
export function CreatePostForm() {
const [state, formAction] = useFormState(createPost, initialState)
return (
<form action={formAction}>
<div>
<input name="title" />
{state.errors?.title && <p>{state.errors.title[0]}</p>}
</div>
<div>
<textarea name="content" />
{state.errors?.content && <p>{state.errors.content[0]}</p>}
</div>
<SubmitButton />
</form>
)
}Server Action with Redirect
// app/actions.ts
'use server'
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'
export async function createPost(formData: FormData) {
const post = await db.post.create({
data: {
title: formData.get('title') as string,
content: formData.get('content') as string,
}
})
revalidatePath('/posts')
redirect(`/posts/${post.id}`)
}Optimistic Updates
// components/todo-list.tsx
'use client'
import { experimental_useOptimistic as useOptimistic } from 'react'
import { toggleTodo } from '@/app/actions'
export function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state, newTodo: Todo) => [...state, newTodo]
)
async function handleSubmit(formData: FormData) {
const title = formData.get('title') as string
const newTodo = { id: crypto.randomUUID(), title, completed: false }
// Optimistically update UI
addOptimisticTodo(newTodo)
// Send to server
await createTodo(formData)
}
return (
<div>
<ul>
{optimisticTodos.map(todo => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
<form action={handleSubmit}>
<input name="title" />
<button type="submit">Add</button>
</form>
</div>
)
}Server Action with Authentication
// app/actions.ts
'use server'
import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'
export async function createPost(formData: FormData) {
const session = await auth()
if (!session) {
redirect('/login')
}
await db.post.create({
data: {
title: formData.get('title') as string,
content: formData.get('content') as string,
authorId: session.user.id,
}
})
revalidatePath('/posts')
}Inline Server Action
// app/posts/page.tsx
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
export default async function Posts() {
const posts = await db.post.findMany()
async function deletePost(formData: FormData) {
'use server'
const id = formData.get('id') as string
await db.post.delete({ where: { id } })
revalidatePath('/posts')
}
return (
<ul>
{posts.map(post => (
<li key={post.id}>
{post.title}
<form action={deletePost}>
<input type="hidden" name="id" value={post.id} />
<button type="submit">Delete</button>
</form>
</li>
))}
</ul>
)
}Programmatic Server Action Call
// components/delete-button.tsx
'use client'
import { deletePost } from '@/app/actions'
export function DeleteButton({ postId }: { postId: string }) {
async function handleDelete() {
if (confirm('Are you sure?')) {
await deletePost(postId)
}
}
return (
<button onClick={handleDelete}>
Delete
</button>
)
}
// app/actions.ts
'use server'
export async function deletePost(postId: string) {
await db.post.delete({ where: { id: postId } })
revalidatePath('/posts')
}Revalidation Strategies
// app/actions.ts
'use server'
import { revalidatePath, revalidateTag } from 'next/cache'
export async function updatePost(id: string, data: UpdatePostData) {
await db.post.update({ where: { id }, data })
// Revalidate specific path
revalidatePath('/posts')
revalidatePath(`/posts/${id}`)
// Revalidate all paths in a layout
revalidatePath('/posts', 'layout')
// Revalidate by cache tag
revalidateTag('posts')
}Server Action with File Upload
// app/actions.ts
'use server'
import { writeFile } from 'fs/promises'
import { join } from 'path'
export async function uploadAvatar(formData: FormData) {
const file = formData.get('avatar') as File
if (!file) {
return { error: 'No file uploaded' }
}
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
const path = join(process.cwd(), 'public', 'uploads', file.name)
await writeFile(path, buffer)
return { success: true, path: `/uploads/${file.name}` }
}
// components/upload-form.tsx
'use client'
import { uploadAvatar } from '@/app/actions'
export function UploadForm() {
async function handleSubmit(formData: FormData) {
const result = await uploadAvatar(formData)
if (result.success) {
console.log('Uploaded to:', result.path)
}
}
return (
<form action={handleSubmit}>
<input type="file" name="avatar" accept="image/*" />
<button type="submit">Upload</button>
</form>
)
}Error Handling
// app/actions.ts
'use server'
export async function createPost(formData: FormData) {
try {
await db.post.create({
data: {
title: formData.get('title') as string,
content: formData.get('content') as string,
}
})
revalidatePath('/posts')
return { success: true }
} catch (error) {
console.error('Failed to create post:', error)
return { error: 'Failed to create post' }
}
}
// components/form.tsx
'use client'
export function CreatePostForm() {
const [error, setError] = useState<string | null>(null)
async function handleSubmit(formData: FormData) {
const result = await createPost(formData)
if (result.error) {
setError(result.error)
} else {
// Success
router.push('/posts')
}
}
return (
<form action={handleSubmit}>
{error && <div className="error">{error}</div>}
{/* form fields */}
</form>
)
}Server Action with Cookies
// app/actions.ts
'use server'
import { cookies } from 'next/headers'
export async function setTheme(theme: 'light' | 'dark') {
cookies().set('theme', theme, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 365, // 1 year
path: '/',
})
}
export async function getTheme() {
return cookies().get('theme')?.value ?? 'light'
}Rate Limiting
// app/actions.ts
'use server'
import { ratelimit } from '@/lib/redis'
export async function createPost(formData: FormData) {
const session = await auth()
const { success } = await ratelimit.limit(session.user.id)
if (!success) {
return { error: 'Rate limit exceeded' }
}
// Create post...
}Quick Reference
| Capability | Usage |
|---|---|
| Define | Add 'use server' at top of file or function |
| Form | Pass action to <form action={serverAction}> |
| Programmatic | Call directly: await serverAction(data) |
| Validation | Use Zod/TypeBox before mutations |
| Revalidate | revalidatePath() or revalidateTag() |
| Redirect | redirect() after mutation |
| Errors | Return error objects, handle in client |
| Files | Access via formData.get() as File |
Best Practices
1. Always validate - Use Zod/TypeBox for type-safe validation 2. Revalidate - Call revalidatePath() after mutations 3. Handle errors - Return error objects instead of throwing 4. Auth checks - Verify session before mutations 5. Rate limiting - Protect against abuse 6. Type safety - Define input/output types 7. Optimistic updates - Use useOptimistic for better UX
React Server Components
Server Components (Default)
// app/page.tsx - Server Component by default
import { db } from '@/lib/db'
export default async function Page() {
// Data fetching in Server Component
const users = await db.user.findMany()
return (
<div>
<h1>Users</h1>
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
)
}Benefits of Server Components
- Zero bundle size - Server Components don't add JavaScript to client bundle
- Direct backend access - Query databases, read files, use secrets
- Automatic code splitting - Only Client Components add to bundle
- Streaming - Send UI progressively as data loads
- No client-side waterfalls - Fetch all data in parallel on server
Client Components
// components/counter.tsx
'use client' // Required directive
import { useState } from 'react'
export function Counter() {
const [count, setCount] = useState(0)
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
)
}When to Use Client Components
Use 'use client' when you need:
- Interactivity - onClick, onChange, event handlers
- State - useState, useReducer
- Effects - useEffect, useLayoutEffect
- Browser APIs - localStorage, window, document
- Custom hooks - Any hook using client-only features
- Class components - Component lifecycle methods
Composition Pattern
// app/page.tsx - Server Component
import { ClientWrapper } from './client-wrapper'
import { db } from '@/lib/db'
export default async function Page() {
const data = await db.query()
return (
<div>
{/* Server Component content */}
<h1>Server Content</h1>
{/* Pass data to Client Component */}
<ClientWrapper initialData={data}>
{/* Server Component as children */}
<ServerSidebar />
</ClientWrapper>
</div>
)
}
// components/client-wrapper.tsx
'use client'
export function ClientWrapper({
children,
initialData,
}: {
children: React.ReactNode
initialData: Data
}) {
const [data, setData] = useState(initialData)
return (
<div>
{/* Client Component UI */}
<button onClick={() => refresh()}>Refresh</button>
{/* Server Component children */}
{children}
</div>
)
}Streaming with Suspense
// app/page.tsx
import { Suspense } from 'react'
import { SlowComponent } from './slow-component'
import { FastComponent } from './fast-component'
export default function Page() {
return (
<div>
{/* Renders immediately */}
<FastComponent />
{/* Shows fallback while loading */}
<Suspense fallback={<div>Loading...</div>}>
<SlowComponent />
</Suspense>
</div>
)
}
// components/slow-component.tsx
async function getData() {
await new Promise(resolve => setTimeout(resolve, 3000))
return { data: 'Loaded!' }
}
export async function SlowComponent() {
const data = await getData()
return <div>{data.data}</div>
}Parallel Data Fetching
// app/dashboard/page.tsx
async function getUser() {
return fetch('https://api.example.com/user')
}
async function getPosts() {
return fetch('https://api.example.com/posts')
}
export default async function Dashboard() {
// Fetch in parallel
const [user, posts] = await Promise.all([
getUser(),
getPosts(),
])
return (
<div>
<UserProfile user={user} />
<PostsList posts={posts} />
</div>
)
}Sequential Data Fetching
// app/artist/[id]/page.tsx
async function getArtist(id: string) {
return fetch(`https://api.example.com/artists/${id}`)
}
async function getAlbums(artistId: string) {
return fetch(`https://api.example.com/artists/${artistId}/albums`)
}
export default async function ArtistPage({ params }: { params: { id: string } }) {
// Sequential: albums depends on artist
const artist = await getArtist(params.id)
const albums = await getAlbums(artist.id)
return (
<div>
<h1>{artist.name}</h1>
<Albums albums={albums} />
</div>
)
}Preloading Data
// lib/data.ts
import { cache } from 'react'
export const getUser = cache(async (id: string) => {
return db.user.findUnique({ where: { id } })
})
// components/user-profile.tsx
export async function UserProfile({ userId }: { userId: string }) {
const user = await getUser(userId)
return <div>{user.name}</div>
}
// app/page.tsx
import { getUser } from '@/lib/data'
import { UserProfile } from '@/components/user-profile'
export default async function Page() {
// Preload
getUser('123')
return (
<div>
{/* This will use cached result */}
<UserProfile userId="123" />
</div>
)
}Server Component Patterns
Pattern: Layout with Data Fetching
// app/dashboard/layout.tsx
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await auth()
const user = await db.user.findUnique({ where: { id: session.userId } })
return (
<div>
<Sidebar user={user} />
<main>{children}</main>
</div>
)
}Pattern: Conditional Client Components
// app/page.tsx
import { ClientComponent } from './client-component'
export default async function Page() {
const data = await fetchData()
// Only render Client Component when needed
if (data.requiresInteractivity) {
return <ClientComponent data={data} />
}
return <div>{data.content}</div>
}Pattern: Server Component with Client Island
// app/blog/[slug]/page.tsx
import { LikeButton } from './like-button'
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug)
return (
<article>
{/* Server-rendered content */}
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
{/* Client island for interactivity */}
<LikeButton postId={post.id} initialLikes={post.likes} />
</article>
)
}Context in Server/Client Components
// app/providers.tsx
'use client'
import { ThemeProvider } from 'next-themes'
export function Providers({ children }: { children: React.ReactNode }) {
return <ThemeProvider>{children}</ThemeProvider>
}
// app/layout.tsx
import { Providers } from './providers'
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html>
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}Third-Party Components
// components/carousel-wrapper.tsx
'use client'
import { Carousel } from 'third-party-carousel'
export function CarouselWrapper({ items }: { items: Item[] }) {
return <Carousel items={items} />
}
// app/page.tsx
import { CarouselWrapper } from '@/components/carousel-wrapper'
export default async function Page() {
const items = await fetchItems()
return <CarouselWrapper items={items} />
}Edge Runtime
// app/api/route.ts
export const runtime = 'edge'
export async function GET() {
return new Response('Hello from Edge!')
}
// app/page.tsx
export const runtime = 'edge'
export default async function Page() {
return <div>Edge-rendered page</div>
}Quick Reference
| Capability | Server Component | Client Component |
|---|---|---|
| Data fetching | ✅ Yes | ⚠️ Use SWR/React Query |
| Backend access | ✅ Yes (DB, files) | ❌ No |
| Event handlers | ❌ No | ✅ Yes |
| State/Effects | ❌ No | ✅ Yes |
| Browser APIs | ❌ No | ✅ Yes |
| Bundle size | 0 KB | Adds to bundle |
| Streaming | ✅ Yes | ❌ No |
Best Practices
1. Default to Server Components - Only use 'use client' when needed 2. Move Client Components down - Push them to leaves of component tree 3. Pass data down - Fetch in Server Components, pass to Client Components 4. Use composition - Nest Server Components inside Client Components via children 5. Cache expensive operations - Use React cache() for deduplication
Related skills
How it compares
Specialized for Next.js 14+ App Router; differs from general React or Pages Router guidance by requiring Server Components by default and enforcing server-side rendering patterns.
FAQ
When should I use 'use client' directive?
Add 'use client' only at leaf boundaries where interactivity is required (forms, event listeners, hooks). Keep parent components as server components to maximize server-side rendering benefits.
How do I handle data fetching in Next.js 14?
Use native fetch with explicit cache directives: next: { revalidate: 60 } for ISR, cache: 'no-store' for dynamic data, or cache: 'force-cache' for static data. Never rely on implicit caching.
What must I validate before Vercel deployment?
Run next build locally to confirm zero type errors, verify NEXT_PUBLIC_* and server-only environment variables, run Lighthouse/PageSpeed to confirm Core Web Vitals > 90, then deploy.
Is Nextjs Developer safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.