dev-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/skills dev-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 implementing any logic, fixing any bug, or changing any behaviour. Use when you need to prove code works, when a bug report arrives, or when modifying existing functionality. Do NOT use for config changes, data migrations, or dependency updates.
development
Use when starting a new feature, when requirements are unclear, when asked to write code without a clear spec, or before any non-trivial implementation. Do NOT use for trivial bug fixes or one-line changes.
development
Use when you want authoritative, source-cited code free from outdated patterns. Use when building with any framework or library where correctness matters. Detects the stack from dependency files, fetches official documentation, implements following documented patterns, and cites sources for every framework-specific decision.
development
Use when preparing to ship a feature, release, or deployment. Use before merging to main, creating a release, or deploying to production. Do NOT use for CI-only changes or internal refactors that don't reach production.