nextjs-expert
Skill0 starsChanged 4 months agoMITView on GitHub
Use when building Next.js 14/15 applications with the App Router. Invoke for routing, layouts, Server Components, Client Components, Server Actions, Route Handlers, authentication, middleware, data fetching, caching, revalidation, streaming, Suspense, loading states, error boundaries, dynamic routes, parallel routes, intercepting routes, or any Next.js architecture question.
- Reads credentials
5 other projects use a version of this.
Adopted by
Most recently taken up by
- saksham-techlabs/ai-builder-skills-packtheir own version, first seen Oct 25, 2026
- cathaysia/skillstheir own version, first seen Oct 23, 2026
- iwritec0de/app-devtheir own version, first seen Apr 19, 2026
- frankxai/agentic-creator-ostheir own version, first seen Mar 28, 2026
- Marine-softdrink524/claude-skillstheir own version, first seen Mar 11, 2026
Other versions5 projects use a version of this.
Word for word · 0
None found.
Edited · 0
None found.
Wrote their own version · 5
- frankxai/agentic-creator-os★ 10same name
- iwritec0de/app-dev★ 3same name
- cathaysia/skills★ 2same name
- Marine-softdrink524/claude-skills★ 2same name
- saksham-techlabs/ai-builder-skills-pack★ 0same name
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository37 other files it gives its agents.
- .agents/skills/vercel-composition-patterns/AGENTS.mdAGENTS.md
- vercel-composition-patterns/AGENTS.mdAGENTS.md
- senior-frontendSkill
- vercel-composition-patternsSkill
- bento-designSkill
- fanned-cardsSkill
- flip-cardSkill
- floating-quotesSkill
- githubSkill
- glassmorphism-uiSkill
- gmail-proxySkill
- googleSkill
- hippocampusSkill
- html-pdf-exportSkill
- iframe-previewSkill
- infinite-canvasSkill
- matrix-textSkill
- nextjs-apiSkill
- pinterestSkill
- railway-deploySkill
- remotion-videoSkill
- scramble-textSkill
- scroll-video-heroSkill
- senior-frontendSkill
- SEOSkill
- ship-websiteSkill
- sound-engineSkill
- stripe-best-practicesSkill
- tilt-cardSkill
- tube-summarySkill
- upload-postSkill
- vercel-composition-patternsSkill
- vercel-deploySkill
- vercel-react-best-practicesSkill
- vertical-fanned-cardsSkill
- video-contentSkill
- war-roomSkill
The file
We split this skill into the rules it gives an agent
3 rules. 0 are common, 2 are distinctive, and 1 is very specific to this project.
0
Common
Most of the 4 edited versions we read keep these.
2
Distinctive
This author's own choices. Worth a look.
1
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 43
Push client boundaries down: Keep 'use client' as low in the tree as possible.
- Distinctive: line 45
Colocation: Keep components, tests, and styles near their routes.
- Distinctive: line 46
Type everything: Use TypeScript strictly.
“Common” is measured: kept by at least 6 in 10 of the 4 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Next.js Expert
Comprehensive Next.js 15 App Router specialist. Adapted from buildwithclaude by Dave Poon (MIT).
Role Definition
You are a senior Next.js engineer specializing in the App Router, React Server Components, and production-grade full-stack applications with TypeScript.
Core Principles
- Server-first: Components are Server Components by default. Only add
'use client'when you need hooks, event handlers, or browser APIs. - Push client boundaries down: Keep
'use client'as low in the tree as possible. - Async params: In Next.js 15,
paramsandsearchParamsarePromisetypes — alwaysawaitthem. - Colocation: Keep components, tests, and styles near their routes.
- Type everything: Use TypeScript strictly.
App Router File Conventions
Route Files
| File | Purpose |
page.tsx | Unique UI for a route, makes it publicly accessible |
layout.tsx | Shared UI wrapper, preserves state across navigations |
loading.tsx | Loading UI using React Suspense |
error.tsx | Error boundary for route segment (must be 'use client') |
not-found.tsx | UI for 404 responses |
template.tsx | Like layout but re-renders on navigation |
default.tsx | Fallback for parallel routes |
route.ts | API endpoint (Route Handler) |
Folder Conventions
| Pattern | Purpose | Example |
folder/ | Route segment | app/blog/ → /blog |
[folder]/ | Dynamic segment | app/blog/[slug]/ → /blog/:slug |
[...folder]/ | Catch-all segment | app/docs/[...slug]/ → /docs/* |
[[...folder]]/ | Optional catch-all | app/shop/[[...slug]]/ → /shop or /shop/* |
(folder)/ | Route group (no URL) | app/(marketing)/about/ → /about |
@folder/ | Named slot (parallel routes) | app/@modal/login/ |
_folder/ | Private folder (excluded) | app/_components/ |
File Hierarchy (render order)
layout.tsx→ 2.template.tsx→ 3.error.tsx(boundary) → 4.loading.tsx(boundary) → 5.not-found.tsx(boundary) → 6.page.tsx
Pages and Routing
Basic Page (Server Component)
// app/about/page.tsx
export default function AboutPage() {
return (
<main>
<h1>About Us</h1>
<p>Welcome to our company.</p>
</main>
)
}Dynamic Routes
// app/blog/[slug]/page.tsx
interface PageProps {
params: Promise<{ slug: string }>
}
export default async function BlogPost({ params }: PageProps) {
const { slug } = await params
const post = await getPost(slug)
return <article>{post.content}</article>
}Search Params
// app/search/page.tsx
interface PageProps {
searchParams: Promise<{ q?: string; page?: string }>
}
export default async function SearchPage({ searchParams }: PageProps) {
const { q, page } = await searchParams
const results = await search(q, parseInt(page || '1'))
return <SearchResults results={results} />
}Static Generation
export async function generateStaticParams() {
const posts = await getAllPosts()
return posts.map((post) => ({ slug: post.slug }))
}
// Allow dynamic params not in generateStaticParams
export const dynamicParams = trueLayouts
Root Layout (Required)
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}Nested Layout with Data Fetching
// app/dashboard/layout.tsx
import { getUser } from '@/lib/get-user'
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
const user = await getUser()
return (
<div className="flex">
<Sidebar user={user} />
<main className="flex-1 p-6">{children}</main>
</div>
)
}Route Groups for Multiple Root Layouts
app/
├── (marketing)/
│ ├── layout.tsx # Marketing layout with <html>/<body>
│ └── about/page.tsx
└── (app)/
├── layout.tsx # App layout with <html>/<body>
└── dashboard/page.tsxMetadata
// Static
export const metadata: Metadata = {
title: 'About Us',
description: 'Learn more about our company',
}
// Dynamic
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params
const post = await getPost(slug)
return {
title: post.title,
openGraph: { title: post.title, images: [post.coverImage] },
}
}
// Template in layouts
export const metadata: Metadata = {
title: { template: '%s | Dashboard', default: 'Dashboard' },
}Server Components vs Client Components
Decision Guide
Server Component (default) when:
- Fetching data or accessing backend resources
- Keeping sensitive info on server (API keys, tokens)
- Reducing client JavaScript bundle
- No interactivity needed
Client Component ('use client') when:
- Using
useState,useEffect,useReducer - Using event handlers (
onClick,onChange) - Using browser APIs (
window,document) - Using custom hooks with state
Composition Patterns
Pattern 1: Server data → Client interactivity
// app/products/page.tsx (Server)
export default async function ProductsPage() {
const products = await getProducts()
return <ProductFilter products={products} />
}
// components/product-filter.tsx (Client)
'use client'
export function ProductFilter({ products }: { products: Product[] }) {
const [filter, setFilter] = useState('')
const filtered = products.filter(p => p.name.includes(filter))
return (
<>
<input onChange={e => setFilter(e.target.value)} />
{filtered.map(p => <ProductCard key={p.id} product={p} />)}
</>
)
}Pattern 2: Children as Server Components
// components/client-wrapper.tsx
'use client'
export function ClientWrapper({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false)
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
{isOpen && children}
</div>
)
}
// app/page.tsx (Server)
export default function Page() {
return (
<ClientWrapper>
<ServerContent /> {/* Still renders on server! */}
</ClientWrapper>
)
}Pattern 3: Providers at the boundary
// app/providers.tsx
'use client'
import { ThemeProvider } from 'next-themes'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient()
export function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider attribute="class" defaultTheme="system">
{children}
</ThemeProvider>
</QueryClientProvider>
)
}Shared Data with cache()
import { cache } from 'react'
export const getUser = cache(async () => {
const response = await fetch('/api/user')
return response.json()
})
// Both layout and page call getUser() — only one fetch happensData Fetching
Async Server Components
export default async function PostsPage() {
const posts = await fetch('https://api.example.com/posts').then(r => r.json())
return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>
}Parallel Data Fetching
export default async function DashboardPage() {
const [user, posts, analytics] = await Promise.all([
getUser(), getPosts(), getAnalytics()
])
return <Dashboard user={user} posts={posts} analytics={analytics} />
}Streaming with Suspense
import { Suspense } from 'react'
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<StatsSkeleton />}>
<SlowStats />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<SlowChart />
</Suspense>
</div>
)
}Caching
// Cache indefinitely (static)
const data = await fetch('https://api.example.com/data')
// Revalidate every hour
const data = await fetch(url, { next: { revalidate: 3600 } })
// No caching (always fresh)
const data = await fetch(url, { cache: 'no-store' })
// Cache with tags
const data = await fetch(url, { next: { tags: ['posts'] } })Loading and Error States
Loading UI
// app/dashboard/loading.tsx
export default function Loading() {
return (
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-1/4 mb-4" />
<div className="space-y-3">
<div className="h-4 bg-gray-200 rounded w-full" />
<div className="h-4 bg-gray-200 rounded w-5/6" />
</div>
</div>
)
}Error Boundary
// app/dashboard/error.tsx
'use client'
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
return (
<div className="p-4 bg-red-50 border border-red-200 rounded">
<h2 className="text-red-800 font-bold">Something went wrong!</h2>
<p className="text-red-600">{error.message}</p>
<button onClick={reset} className="mt-2 px-4 py-2 bg-red-600 text-white rounded">
Try again
</button>
</div>
)
}Not Found
// app/posts/[slug]/page.tsx
import { notFound } from 'next/navigation'
export default async function PostPage({ params }: PageProps) {
const { slug } = await params
const post = await getPost(slug)
if (!post) notFound()
return <article>{post.content}</article>
}Server Actions
Defining Actions
// app/actions.ts
'use server'
import { z } from 'zod'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
const schema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
})
export async function createPost(formData: FormData) {
const session = await auth()
if (!session?.user) throw new Error('Unauthorized')
const parsed = schema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
})
if (!parsed.success) return { error: parsed.error.flatten() }
const post = await db.post.create({
data: { ...parsed.data, authorId: session.user.id },
})
revalidatePath('/posts')
redirect(`/posts/${post.slug}`)
}Form with useFormState and useFormStatus
// components/submit-button.tsx
'use client'
import { useFormStatus } from 'react-dom'
export function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? 'Submitting...' : 'Submit'}
</button>
)
}
// components/create-post-form.tsx
'use client'
import { useFormState } from 'react-dom'
import { createPost } from '@/app/actions'
export function CreatePostForm() {
const [state, formAction] = useFormState(createPost, {})
return (
<form action={formAction}>
<input name="title" />
{state.error?.title && <p className="text-red-500">{state.error.title[0]}</p>}
<textarea name="content" />
<SubmitButton />
</form>
)
}Optimistic Updates
'use client'
import { useOptimistic, useTransition } from 'react'
export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
const [isPending, startTransition] = useTransition()
const [optimisticTodos, addOptimistic] = useOptimistic(
initialTodos,
(state, newTodo: string) => [...state, { id: 'temp', title: newTodo, completed: false }]
)
async function handleSubmit(formData: FormData) {
const title = formData.get('title') as string
startTransition(async () => {
addOptimistic(title)
await addTodo(formData)
})
}
return (
<>
<form action={handleSubmit}>
<input name="title" />
<button>Add</button>
</form>
<ul>
{optimisticTodos.map(todo => (
<li key={todo.id} className={todo.id === 'temp' ? 'opacity-50' : ''}>{todo.title}</li>
))}
</ul>
</>
)
}Revalidation
'use server'
import { revalidatePath, revalidateTag } from 'next/cache'
export async function updatePost(id: string, formData: FormData) {
await db.post.update({ where: { id }, data: { ... } })
revalidateTag(`post-${id}`) // Invalidate by cache tag
revalidatePath('/posts') // Invalidate specific page
revalidatePath(`/posts/${id}`) // Invalidate dynamic route
revalidatePath('/posts', 'layout') // Invalidate layout and all pages under it
}Route Handlers (API Routes)
Basic CRUD
// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const page = parseInt(searchParams.get('page') ?? '1')
const limit = parseInt(searchParams.get('limit') ?? '10')
const [posts, total] = await Promise.all([
db.post.findMany({ skip: (page - 1) * limit, take: limit }),
db.post.count(),
])
return NextResponse.json({ data: posts, pagination: { page, limit, total } })
}
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 Handler
// app/api/posts/[id]/route.ts
export async function GET(request: Request, { 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)
}
export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
await db.post.delete({ where: { id } })
return new NextResponse(null, { status: 204 })
}Streaming / SSE
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: ${JSON.stringify({ count: i })}\n\n`))
await new Promise(r => setTimeout(r, 1000))
}
controller.close()
},
})
return new Response(stream, {
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
})
}Parallel and Intercepting Routes
Parallel Routes (Slots)
app/
├── @modal/
│ ├── (.)photo/[id]/page.tsx # Intercepted route (modal)
│ └── default.tsx
├── photo/[id]/page.tsx # Full page route
├── layout.tsx
└── page.tsx// app/layout.tsx
export default function Layout({ children, modal }: {
children: React.ReactNode
modal: React.ReactNode
}) {
return <>{children}{modal}</>
}Modal Component
'use client'
import { useRouter } from 'next/navigation'
export function Modal({ children }: { children: React.ReactNode }) {
const router = useRouter()
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center"
onClick={() => router.back()}>
<div className="bg-white rounded-lg p-6 max-w-2xl" onClick={e => e.stopPropagation()}>
{children}
</div>
</div>
)
}Authentication (NextAuth.js v5 / Auth.js)
Setup
// auth.ts
import NextAuth from 'next-auth'
import GitHub from 'next-auth/providers/github'
import Credentials from 'next-auth/providers/credentials'
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
GitHub({ clientId: process.env.GITHUB_ID, clientSecret: process.env.GITHUB_SECRET }),
Credentials({
credentials: { email: {}, password: {} },
authorize: async (credentials) => {
const user = await getUserByEmail(credentials.email as string)
if (!user || !await verifyPassword(credentials.password as string, user.password)) return null
return user
},
}),
],
callbacks: {
jwt: ({ token, user }) => { if (user) { token.id = user.id; token.role = user.role } return token },
session: ({ session, token }) => { session.user.id = token.id as string; session.user.role = token.role as string; return session },
},
})
// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth'
export const { GET, POST } = handlersMiddleware Protection
// middleware.ts
export { auth as middleware } from '@/auth'
export const config = {
matcher: ['/dashboard/:path*', '/api/protected/:path*'],
}Server Component Auth Check
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
export default async function DashboardPage() {
const session = await auth()
if (!session) redirect('/login')
return <h1>Welcome, {session.user?.name}</h1>
}Server Action Auth Check
'use server'
import { auth } from '@/auth'
export async function deletePost(id: string) {
const session = await auth()
if (!session?.user) throw new Error('Unauthorized')
const post = await db.post.findUnique({ where: { id } })
if (post?.authorId !== session.user.id) throw new Error('Forbidden')
await db.post.delete({ where: { id } })
revalidatePath('/posts')
}Route Segment Config
export const dynamic = 'force-dynamic' // 'auto' | 'force-dynamic' | 'error' | 'force-static'
export const revalidate = 3600 // seconds
export const runtime = 'nodejs' // or 'edge'
export const maxDuration = 30 // secondsAnti-Patterns to Avoid
- ❌ Adding
'use client'to entire pages — push it down to interactive leaves - ❌ Fetching data in Client Components when it could be a Server Component
- ❌ Sequential
awaitwhen fetches are independent — usePromise.all() - ❌ Passing functions as props across server/client boundary (use Server Actions)
- ❌ Using
useEffectfor data fetching in App Router (use async Server Components) - ❌ Forgetting
await paramsin Next.js 15 (they're Promises now) - ❌ Missing
loading.tsxor<Suspense>boundaries for async pages - ❌ Not validating Server Action inputs (always validate with zod)
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

