standards-nextjs/SKILL.md
Next.js 15 App Router best practices. Auto-load when working in app/, src/app/, components/, server actions, or route handlers. Trigger on user request or when writing any Next.js-specific code.
npx skillsauth add paulund/ai standards-nextjsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Warning: Next.js 15 has breaking changes from older versions. Before writing non-trivial code, check the relevant docs in node_modules/next/dist/docs/.
{ error: string } so clients handle failures gracefullyPromise.all for independent queriesparams and searchParams are Promises in Next.js 15+ — always await them before accessing propertiesDefault to Server Components. Add 'use client' only when you need:
useState, useReducer, useEffect, or other React hookswindow, document, localStorage)onClick, onChange, etc.)Never import server-only modules (Prisma, server-only, environment secrets) into a Client Component. Pass server data down as props.
next/dynamic with ssr: false cannot be called from a Server Component — it causes a build error. Wrap it in a thin 'use client' file (e.g. foo-lazy.tsx) and import that from the Server Component instead:
// components/foo-lazy.tsx
'use client'
import dynamic from 'next/dynamic'
export const FooLazy = dynamic(() => import('./foo').then((m) => m.Foo), { ssr: false })
// app/page.tsx (Server Component)
import { FooLazy } from '@/components/foo-lazy'
// GOOD — server component fetches, client component displays
export default async function Page() {
const posts = await getPosts()
return <PostList posts={posts} /> // PostList can be 'use client'
}
'use server'
import { z } from 'zod'
export async function createPost(formData: FormData) {
const result = postSchema.safeParse(Object.fromEntries(formData))
if (!result.success) return { error: result.error.flatten() }
const post = await db.post.create({ data: result.data })
revalidatePath(`/projects/${post.projectId}`)
return { post }
}
Rules:
Date objects, no Prisma instances)revalidatePath or revalidateTag after any write{ error: string }route.ts)Only create a route handler when you need a real HTTP endpoint:
For everything else, use server actions.
Fetch data in Server Components — not in Client Components via useEffect.
Use cache() from React for request-scoped memoization (same request, multiple callers):
import { cache } from 'react'
export const getProject = cache(async (id: string) => {
return db.project.findUniqueOrThrow({ where: { id } })
})
Extract all Prisma reads into src/lib/queries/ (one file per model). Pages call typed query functions — they never import the database client directly. Mutations stay in src/lib/actions/.
Rule: reads in lib/queries/, writes in lib/actions/, no page imports @/lib/db directly. lib/queries/ functions must NOT have 'use server'.
Next.js 15+ changed defaults — fetch is uncached by default. Be explicit:
fetch(url, { cache: 'force-cache' }) // cached
fetch(url, { cache: 'no-store' }) // never cached
fetch(url, { next: { revalidate: 60 } }) // ISR
After mutations: revalidatePath('/projects') or revalidateTag('projects')
Wrap slow server component trees in <Suspense> with a skeleton. Use loading.tsx for route-level skeletons.
export default function Page() {
return (
<Suspense fallback={<PostListSkeleton />}>
<PostList />
</Suspense>
)
}
| File | Purpose |
| --------------- | ----------------------------------------------------- |
| page.tsx | Route UI — receives params and searchParams props |
| layout.tsx | Shared UI wrapper — receives children prop |
| loading.tsx | Skeleton shown while the page suspends |
| error.tsx | Error boundary — must be 'use client' |
| not-found.tsx | 404 UI — call notFound() to trigger |
| route.ts | HTTP endpoint (GET, POST, etc.) |
| template.tsx | Like layout but remounts on navigation |
In Next.js 15+, params and searchParams are Promises — always await them:
export default async function Page({
params,
searchParams,
}: {
params: Promise<{ id: string }>
searchParams: Promise<{ q?: string }>
}) {
const { id } = await params
const { q } = await searchParams
}
Always export metadata or generateMetadata from pages. Never set <title> with a <Head> tag.
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const project = await getProject((await params).id)
return { title: project.name }
}
Pattern: react-hook-form + Zod resolver on the client → server action validates the same schema.
next/image — never raw <img>next/link for internal navigation — never raw <a>NEXT_PUBLIC_ prefix — never access in client componentssrc/lib/env.ts using Zoddevelopment
Use when the user wants to run the project's lint + types + build sequence as a gate before pushing, opening a PR, or merging. Invoked by chained dev skills between phases. Trigger phrases - "/quality-gate", "run the quality gate", "check it builds".
tools
Use when the user wants to verify a PR's feature works at runtime by booting the dev server, exercising the affected UI via Chrome DevTools MCP, and posting a screenshot summary back to the PR. Idempotent — skips if `verified` or `verify-failed` is already on the PR. Trigger phrases - "/pr-verify", "verify this PR", "runtime check the pr".
testing
Use when the user wants a security-focused review pass on a PR with findings actioned as commits on the same branch. Trigger phrases - "/pr-security-review", "security review and fix".
testing
Use when the user wants to open a pull request for an already-pushed branch that implements a specific issue. Idempotent — returns the existing PR if one is already open for the branch. Trigger phrases - "/pr-open", "open the pr", "create pr for this branch".