Help us improve
Share bugs, ideas, or general feedback.
Provides Next.js 16 expertise covering App Router, server/client components, data caching, and production gotchas like async params and route collisions.
npx claudepluginhub frankxai/claude-skills-library --plugin claude-skills-libraryHow this skill is triggered — by the user, by Claude, or both
Slash command
/claude-skills-library:nextjs-expertThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
**Version:** 2.1 (Next.js 16+ with MCP Integration)
Guides building, debugging, and architecting Next.js App Router apps: routing, Server Components, Server Actions, layouts, middleware, data fetching, rendering strategies, Vercel deployment.
Provides Next.js 14+ best practices for App Router structure, Server/Client Components, API routes, loading/error UI, and file conventions.
Builds Next.js 14+ App Router applications with server components, actions, data fetching, streaming SSR, SEO metadata, loading/error boundaries, and Vercel deployment.
Share bugs, ideas, or general feedback.
Version: 2.1 (Next.js 16+ with MCP Integration) Author: FrankX AI Systems Last Updated: 2026-05-13
maxDuration = 300s for Hobby/Pro.next.config.ts (or .mjs) for framework. Platform config: vercel.ts is the recommended way (typed VercelConfig, env-aware logic, programmatic functions per vercel:knowledge-update 2026-02-27). vercel.json still works for simple cases. FrankX currently uses vercel.json + next.config.mjs — migrate to vercel.ts when you need typed config, dynamic logic, or env-var access at config time.cacheComponents: true at top level of next.config, NOT under experimental.These bite real builds. Memorize them.
params is now a Promise — alwaysEvery dynamic route page, layout, route handler, and generateMetadata receives params as Promise<{...}>. Synchronous access (the Next 15 pattern) builds fine but 404s every request in production.
// BROKEN in Next 16 (compiles, silently 404s)
export default function Page({ params }: { params: { slug: string } }) {
return <div>{params.slug}</div>
}
// CORRECT — async + await
export default async function Page({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
return <div>{slug}</div>
}
Same rule for generateMetadata, generateStaticParams, route handlers, and searchParams (also a Promise).
app/<name>.ts collides with app/<name>/page.tsx under TurbopackIf you have app/sitemap.ts, app/robots.ts, app/manifest.ts, app/icon.ts, etc., do NOT also create app/sitemap/page.tsx — the prod build fails under Turbopack with a route-collision error. next dev masks this; only next build / Vercel surfaces it. Pick distinct names (/sitemap-info/page.tsx).
next build before pushingTurbopack catches collisions, missing await params, and metadata conflicts only at build time. Dev server is lenient. Always run pnpm build (or npm run build) locally before pushing to main.
You are a Next.js expert with deep knowledge of Next.js 16+, React Server Components, Server Actions, and modern web development patterns. You have access to two powerful MCP servers that provide real-time application insights and comprehensive documentation.
When a Next.js 16+ project runs next dev, you automatically gain access to:
Provides high-level development guidance:
App Router Best Practices (Next.js 16+):
// app/layout.tsx - Root layout with metadata
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: {
template: '%s | My App',
default: 'My App',
},
description: 'Built with Next.js 16',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
Server Components (Default):
// app/page.tsx - Server Component by default
async function getData() {
const res = await fetch('https://api.example.com/data', {
next: { revalidate: 3600 } // ISR with 1 hour revalidation
})
return res.json()
}
export default async function Page() {
const data = await getData()
return <div>{data.title}</div>
}
Client Components (Interactive):
// 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>
)
}
Form Handling with Server Actions:
// app/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
export async function createPost(formData: FormData) {
const title = formData.get('title')
const content = formData.get('content')
// Database operation
await db.post.create({
data: { title, content }
})
revalidatePath('/posts')
return { success: true }
}
Using Server Actions:
// app/new-post/page.tsx
import { createPost } from '../actions'
export default function NewPost() {
return (
<form action={createPost}>
<input name="title" required />
<textarea name="content" required />
<button type="submit">Create Post</button>
</form>
)
}
Fetch with Caching Strategies:
// Static data (cached permanently)
fetch('https://api.example.com/static', { cache: 'force-cache' })
// Dynamic data (no cache)
fetch('https://api.example.com/live', { cache: 'no-store' })
// Revalidated data (ISR)
fetch('https://api.example.com/timed', {
next: { revalidate: 60 } // Revalidate every 60 seconds
})
// Tagged cache for selective revalidation
fetch('https://api.example.com/posts', {
next: { tags: ['posts'] }
})
Manual Cache Revalidation:
import { revalidateTag, revalidatePath } from 'next/cache'
// Revalidate specific tag
revalidateTag('posts')
// Revalidate specific path
revalidatePath('/posts')
revalidatePath('/posts/[slug]', 'page') // Specific dynamic route
Modern API Route:
// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const query = searchParams.get('query')
const posts = await db.post.findMany({
where: query ? { title: { contains: query } } : {}
})
return NextResponse.json(posts)
}
export async function POST(request: NextRequest) {
const body = await request.json()
const post = await db.post.create({ data: body })
return NextResponse.json(post, { status: 201 })
}
Dynamic Route Handlers (Next.js 16 — params is a Promise):
// app/api/posts/[id]/route.ts
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const post = await db.post.findUnique({
where: { id }
})
if (!post) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
return NextResponse.json(post)
}
Image Optimization:
import Image from 'next/image'
// Local images
import heroImage from '@/public/hero.jpg'
<Image
src={heroImage}
alt="Hero"
priority // LCP image
placeholder="blur" // Automatic blur placeholder
/>
// Remote images (configure in next.config.js)
<Image
src="https://example.com/image.jpg"
alt="Remote"
width={800}
height={600}
loading="lazy"
/>
Font Optimization:
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 }) {
return (
<html lang="en" className={`${inter.variable} ${robotoMono.variable}`}>
<body className="font-sans">{children}</body>
</html>
)
}
Lazy Loading Components:
import dynamic from 'next/dynamic'
const DynamicChart = dynamic(() => import('@/components/Chart'), {
loading: () => <p>Loading chart...</p>,
ssr: false // Client-side only
})
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
<DynamicChart data={data} />
</div>
)
}
Dynamic Metadata (Next.js 16 — params is a Promise):
// app/posts/[slug]/page.tsx
import type { Metadata } from 'next'
type Props = {
params: Promise<{ slug: string }>
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
const post = await getPost(slug)
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [post.coverImage],
type: 'article',
publishedTime: post.publishedAt,
authors: [post.author.name],
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.excerpt,
images: [post.coverImage],
},
}
}
export default async function PostPage({ params }: Props) {
const { slug } = await params
const post = await getPost(slug)
return <article>{/* Post content */}</article>
}
Watch out for metadata-file vs page-route collisions. If app/sitemap.ts, app/robots.ts, app/manifest.ts, or app/icon.ts exists, never create app/<sameName>/page.tsx. Turbopack fails the prod build; next dev masks it. Use a distinct route name.
Static Metadata:
export const metadata: Metadata = {
title: 'My Page',
description: 'Page description',
keywords: ['next.js', 'react', 'typescript'],
authors: [{ name: 'Author Name' }],
robots: {
index: true,
follow: true,
},
}
Authentication Middleware:
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const token = request.cookies.get('token')
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
export const config = {
matcher: ['/dashboard/:path*', '/api/:path*'],
}
Custom Headers Middleware:
export function middleware(request: NextRequest) {
const response = NextResponse.next()
response.headers.set('X-Custom-Header', 'my-value')
response.headers.set('X-Frame-Options', 'DENY')
return response
}
Configuration (Next.js 16 — next.config.ts is canonical, .mjs still supported):
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
// Cache Components (formerly PPR) — top-level, NOT under experimental in Next 16
cacheComponents: true,
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'example.com',
pathname: '/images/**',
},
],
},
// Server Actions config is top-level in Next 15+
serverActions: {
bodySizeLimit: '2mb',
},
}
export default nextConfig
Vercel platform config (modern): Prefer vercel.ts for typed platform config. vercel.json still works for simple cases (FrankX uses this — vercel.json with framework, cleanUrls, ignoreCommand, headers). When you need types, env-aware logic, or programmatic functions config, migrate to vercel.ts.
Using Environment Variables:
// Server-side (Server Components, API Routes, Server Actions)
const apiKey = process.env.API_KEY
// Client-side (must be prefixed with NEXT_PUBLIC_)
const publicKey = process.env.NEXT_PUBLIC_PUBLISHABLE_KEY
Check MCP Connection:
claude mcp list to verify next-devtools-mcp is connectednpm run dev (auto-connects built-in MCP in Next.js 16+)Query Documentation:
Monitor Application:
Check Runtime Errors:
Analyze Performance:
Verify Best Practices:
Upgrade Path:
Validate Changes:
Streaming with Suspense:
// app/posts/page.tsx
import { Suspense } from 'react'
async function Posts() {
const posts = await getPosts() // Slow data fetch
return <PostsList posts={posts} />
}
export default function PostsPage() {
return (
<div>
<h1>Posts</h1>
<Suspense fallback={<PostsSkeleton />}>
<Posts />
</Suspense>
</div>
)
}
Loading UI:
// app/dashboard/loading.tsx
export default function Loading() {
return <div>Loading dashboard...</div>
}
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>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
)
}
Not Found:
// app/posts/[slug]/not-found.tsx
export default function NotFound() {
return (
<div>
<h2>Post Not Found</h2>
<p>Could not find the requested post.</p>
</div>
)
}
// Trigger from page
import { notFound } from 'next/navigation'
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const post = await getPost(slug)
if (!post) {
notFound()
}
return <article>{post.content}</article>
}
Parallel (Fastest):
async function Page() {
// Fetch in parallel
const [user, posts, comments] = await Promise.all([
getUser(),
getPosts(),
getComments(),
])
return <Dashboard user={user} posts={posts} comments={comments} />
}
Sequential (When Dependent):
async function Page() {
const user = await getUser()
const posts = await getUserPosts(user.id) // Depends on user
return <Profile user={user} posts={posts} />
}
In Next.js 16, Partial Prerendering matured into "Cache Components" and is enabled at the top level (no longer experimental):
// next.config.ts
const nextConfig: NextConfig = {
cacheComponents: true,
}
Use the 'use cache' directive on a component or function to mark it static; everything else stays dynamic by default. Combine with <Suspense> boundaries — Next will stream the dynamic shell while serving the cached parts instantly from the CDN.
'use client' directive at top of filecacheComponents: true + 'use cache'maxDuration 300s, Node 24 LTSDynamic page 404s in prod (works in dev):
params.slug synchronously. In Next 16, params is Promise<{...}>. Make the function async and await params before destructuring.Prod build fails: "duplicate route" or metadata collision:
app/<name>.ts (sitemap, robots, manifest, icon) AND app/<name>/page.tsx. Rename the page route."use client" Required:
Hydration Errors:
suppressHydrationWarningCache Not Invalidating:
Server Actions Not Working:
serverActions is top-level in next.config, not under experimentalaction={...} attribute uses the imported function referenceWhen invoked, leverage both MCP servers:
Remember: You have real-time access to the application's internals when in dev mode. Use this to provide accurate, context-aware solutions.