Guide for implementing Next.js - a React framework for production with server-side rendering, static generation, and modern web features. Use when building Next.js applications, implementing App Router, working with server components, data fetching, routing, or optimizing performance.
78
Quality
77%
Does it follow best practices?
Impact
Pending
No eval scenarios have been run
Passed
No known issues
Optimize this skill with Tessl
npx tessl skill review --optimize ./claude/skills/nextjs/SKILL.mdNext.js is a React framework for building full-stack web applications with server-side rendering, static generation, and powerful optimization features built-in.
https://nextjs.org/docs/llms.txt
Use this skill when:
App Router (Recommended for v13+):
app/ directoryPages Router (Legacy):
pages/ directorygetStaticProps, getServerSideProps, getInitialPropsapp/ are Server Components unless marked with 'use client'npx create-next-app@latest my-app
# or
yarn create next-app my-app
# or
pnpm create next-app my-app
# or
bun create next-app my-appInteractive Setup Prompts:
src/ directory? (Optional)npm install next@latest react@latest react-dom@latestpackage.json scripts:
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
}
}my-app/
├── app/ # App Router (v13+)
│ ├── layout.tsx # Root layout
│ ├── page.tsx # Home page
│ ├── loading.tsx # Loading UI
│ ├── error.tsx # Error UI
│ ├── not-found.tsx # 404 page
│ ├── global.css # Global styles
│ └── [folder]/ # Route segments
├── public/ # Static assets
├── components/ # React components
├── lib/ # Utility functions
├── next.config.js # Next.js configuration
├── package.json
└── tsconfig.jsonpage.tsx - Page UI for routelayout.tsx - Shared UI for segment and childrenloading.tsx - Loading UI (wraps page in Suspense)error.tsx - Error UI (wraps page in Error Boundary)not-found.tsx - 404 UIroute.ts - API endpoint (Route Handler)template.tsx - Re-rendered layout UIdefault.tsx - Parallel route fallbackStatic Route:
app/
├── page.tsx → /
├── about/
│ └── page.tsx → /about
└── blog/
└── page.tsx → /blogDynamic Route:
// app/blog/[slug]/page.tsx
export default function BlogPost({ params }: { params: { slug: string } }) {
return <h1>Post: {params.slug}</h1>
}Catch-all Route:
// app/shop/[...slug]/page.tsx
export default function Shop({ params }: { params: { slug: string[] } }) {
return <h1>Category: {params.slug.join('/')}</h1>
}Optional Catch-all:
// app/docs/[[...slug]]/page.tsx
// Matches /docs, /docs/a, /docs/a/b, etc.Organize routes without affecting URL:
app/
├── (marketing)/ # Group without URL segment
│ ├── about/page.tsx → /about
│ └── blog/page.tsx → /blog
└── (shop)/
├── products/page.tsx → /products
└── cart/page.tsx → /cartRender multiple pages in same layout:
app/
├── @team/ # Slot
│ └── page.tsx
├── @analytics/ # Slot
│ └── page.tsx
└── layout.tsx # Uses both slots// app/layout.tsx
export default function Layout({
children,
team,
analytics,
}: {
children: React.ReactNode
team: React.ReactNode
analytics: React.ReactNode
}) {
return (
<>
{children}
{team}
{analytics}
</>
)
}Intercept routes to show in modal:
app/
├── feed/
│ └── page.tsx
├── photo/
│ └── [id]/
│ └── page.tsx
└── (..)photo/ # Intercepts /photo/[id]
└── [id]/
└── page.tsx// app/layout.tsx
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<section>
<nav>Dashboard Nav</nav>
{children}
</section>
)
}Layouts are:
Components in app/ are Server Components by default:
// app/page.tsx (Server Component)
async function getData() {
const res = await fetch('https://api.example.com/data')
return res.json()
}
export default async function Page() {
const data = await getData()
return <div>{data.title}</div>
}Benefits:
Limitations:
Mark components with 'use client' directive:
// components/counter.tsx
'use client'
import { useState } from 'react'
export function Counter() {
const [count, setCount] = useState(0)
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
)
}Use Client Components for:
// app/page.tsx (Server Component)
import { ClientComponent } from './client-component'
export default function Page() {
return (
<div>
<h1>Server-rendered content</h1>
<ClientComponent />
</div>
)
}// app/posts/page.tsx
async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
next: { revalidate: 3600 } // Revalidate every hour
})
if (!res.ok) throw new Error('Failed to fetch')
return res.json()
}
export default async function PostsPage() {
const posts = await getPosts()
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}Force Cache (Default):
fetch('https://api.example.com/data', { cache: 'force-cache' })No Store (Dynamic):
fetch('https://api.example.com/data', { cache: 'no-store' })Revalidate:
fetch('https://api.example.com/data', {
next: { revalidate: 3600 } // Seconds
})Tag-based Revalidation:
fetch('https://api.example.com/data', {
next: { tags: ['posts'] }
})
// Revalidate elsewhere:
import { revalidateTag } from 'next/cache'
revalidateTag('posts')async function getData() {
const [posts, users] = await Promise.all([
fetch('https://api.example.com/posts').then(r => r.json()),
fetch('https://api.example.com/users').then(r => r.json()),
])
return { posts, users }
}async function getData() {
const post = await fetch(`https://api.example.com/posts/${id}`).then(r => r.json())
const author = await fetch(`https://api.example.com/users/${post.authorId}`).then(r => r.json())
return { post, author }
}// app/api/hello/route.ts
export async function GET(request: Request) {
return Response.json({ message: 'Hello' })
}
export async function POST(request: Request) {
const body = await request.json()
return Response.json({ received: body })
}// app/api/posts/[id]/route.ts
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const post = await getPost(params.id)
return Response.json(post)
}
export async function DELETE(
request: Request,
{ params }: { params: { id: string } }
) {
await deletePost(params.id)
return new Response(null, { status: 204 })
}export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const id = searchParams.get('id')
const cookies = request.headers.get('cookie')
return Response.json({ id })
}// JSON
return Response.json({ data: 'value' })
// Text
return new Response('Hello', { headers: { 'Content-Type': 'text/plain' } })
// Redirect
return Response.redirect('https://example.com')
// Status codes
return new Response('Not Found', { status: 404 })import Link from 'next/link'
export default function Page() {
return (
<>
<Link href="/about">About</Link>
<Link href="/blog/post-1">Post 1</Link>
<Link href={{ pathname: '/blog/[slug]', query: { slug: 'post-1' } }}>
Post 1 (alternative)
</Link>
</>
)
}'use client'
import { useRouter } from 'next/navigation'
export function NavigateButton() {
const router = useRouter()
return (
<button onClick={() => router.push('/dashboard')}>
Dashboard
</button>
)
}Router Methods:
router.push(href) - Navigate to routerouter.replace(href) - Replace current historyrouter.refresh() - Refresh current routerouter.back() - Navigate backrouter.forward() - Navigate forwardrouter.prefetch(href) - Prefetch routeimport { redirect } from 'next/navigation'
export default async function Page() {
const session = await getSession()
if (!session) {
redirect('/login')
}
return <div>Protected content</div>
}// app/page.tsx
import { Metadata } from 'next'
export const metadata: Metadata = {
title: 'My Page',
description: 'Page description',
keywords: ['nextjs', 'react'],
openGraph: {
title: 'My Page',
description: 'Page description',
images: ['/og-image.jpg'],
},
twitter: {
card: 'summary_large_image',
title: 'My Page',
description: 'Page description',
images: ['/twitter-image.jpg'],
},
}
export default function Page() {
return <div>Content</div>
}// app/blog/[slug]/page.tsx
export async function generateMetadata({ params }): Promise<Metadata> {
const post = await getPost(params.slug)
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [post.coverImage],
},
}
}favicon.ico, icon.png, apple-icon.png - Faviconsopengraph-image.png, twitter-image.png - Social imagesrobots.txt - Robots filesitemap.xml - Sitemapimport Image from 'next/image'
export default function Page() {
return (
<>
{/* Local image */}
<Image
src="/profile.png"
alt="Profile"
width={500}
height={500}
/>
{/* Remote image */}
<Image
src="https://example.com/image.jpg"
alt="Remote"
width={500}
height={500}
/>
{/* Responsive fill */}
<div style={{ position: 'relative', width: '100%', height: '400px' }}>
<Image
src="/hero.jpg"
alt="Hero"
fill
style={{ objectFit: 'cover' }}
/>
</div>
{/* Priority loading */}
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
priority
/>
</>
)
}Image Props:
src - Image path (local or URL)alt - Alt text (required)width, height - Dimensions (required unless fill)fill - Fill parent containersizes - Responsive sizesquality - 1-100 (default 75)priority - Preload imageplaceholder - 'blur' | 'empty'blurDataURL - Data URL for blur// next.config.js
module.exports = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'example.com',
pathname: '/images/**',
},
],
},
}// app/layout.tsx
import { Inter, Roboto_Mono } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap',
})
const robotoMono = Roboto_Mono({
subsets: ['latin'],
display: 'swap',
variable: '--font-roboto-mono',
})
export default function RootLayout({ children }) {
return (
<html lang="en" className={`${inter.className} ${robotoMono.variable}`}>
<body>{children}</body>
</html>
)
}import localFont from 'next/font/local'
const myFont = localFont({
src: './fonts/my-font.woff2',
display: 'swap',
variable: '--font-my-font',
})// app/dashboard/loading.tsx
export default function Loading() {
return <div>Loading dashboard...</div>
}// app/page.tsx
import { Suspense } from 'react'
async function Posts() {
const posts = await getPosts()
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>
}
export default function Page() {
return (
<div>
<h1>My Posts</h1>
<Suspense fallback={<div>Loading posts...</div>}>
<Posts />
</Suspense>
</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>
<p>{error.message}</p>
<button onClick={() => reset()}>Try again</button>
</div>
)
}// 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>
)
}// app/not-found.tsx
export default function NotFound() {
return (
<div>
<h2>404 - Not Found</h2>
<p>Could not find requested resource</p>
</div>
)
}
// Trigger programmatically
import { notFound } from 'next/navigation'
export default async function Page({ params }) {
const post = await getPost(params.id)
if (!post) {
notFound()
}
return <div>{post.title}</div>
}// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
// Authentication check
const token = request.cookies.get('token')
if (!token) {
return NextResponse.redirect(new URL('/login', request.url))
}
// Add custom header
const response = NextResponse.next()
response.headers.set('x-custom-header', 'value')
return response
}
export const config = {
matcher: ['/dashboard/:path*', '/api/:path*'],
}# .env.local
DATABASE_URL=postgresql://...
NEXT_PUBLIC_API_URL=https://api.example.com// Server-side only
const dbUrl = process.env.DATABASE_URL
// Client and server (NEXT_PUBLIC_ prefix)
const apiUrl = process.env.NEXT_PUBLIC_API_URL/** @type {import('next').NextConfig} */
const nextConfig = {
// React strict mode
reactStrictMode: true,
// Image domains
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'example.com' },
],
},
// Redirects
async redirects() {
return [
{
source: '/old-page',
destination: '/new-page',
permanent: true,
},
]
},
// Rewrites
async rewrites() {
return [
{
source: '/api/:path*',
destination: 'https://api.example.com/:path*',
},
]
},
// Headers
async headers() {
return [
{
source: '/(.*)',
headers: [
{ key: 'X-Frame-Options', value: 'DENY' },
],
},
]
},
// Environment variables
env: {
CUSTOM_KEY: 'value',
},
}
module.exports = nextConfignext/image for automatic optimizationpriority for above-fold images, lazy load below-fold// app/dashboard/layout.tsx
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
export default async function DashboardLayout({ children }) {
const session = await getSession()
if (!session) {
redirect('/login')
}
return <>{children}</>
}// app/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
export async function createPost(formData: FormData) {
const title = formData.get('title')
await db.post.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" type="text" required />
<button type="submit">Create</button>
</form>
)
}// Generate static params for dynamic routes
export async function generateStaticParams() {
const posts = await getPosts()
return posts.map(post => ({
slug: post.slug,
}))
}
export default async function Post({ params }) {
const post = await getPost(params.slug)
return <article>{post.content}</article>
}# Install Vercel CLI
npm i -g vercel
# Deploy
vercel# Build
npm run build
# Start production server
npm startRequirements:
output: 'standalone' in next.config.js (optional, reduces size)FROM node:18-alpine AS base
FROM base AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 3000
CMD ["node", "server.js"]Hydration errors
Images not loading
next.config.js/ for public)API route 404
route.ts/js not index.tsapp/api/ directory"use client" errors
'use client' to components using hooks'use client'Metadata not updating
When building with Next.js:
create-next-appb1b2fe0
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.