--- name: sdlc-nextjs-conventions description: | Next.js project structure, App Router conventions, file-based routing primitives, layouts, error boundaries, metadata, image and font optimization, configuration. Apply when implementing or modifying Next.js features. Use this skill to: - Pick the correct file convention (page/layout/loading/error/not-found/route/template/default). - Structure a feature within the App Router tree. - Set up metadata for SEO and OpenGraph. - Configure next.config.js for headers, redirects, image domains. - Use built-in fonts and images correctly. Do NOT use this skill for: - Server vs Client component boundaries (see server-component-patterns). - Data fetching patterns (see nextjs-data-fetching). - Complex routing (parallel/intercepting routes — see nextjs-routing). - Testing (see nextjs-testing). paths: ["app/**", "pages/**", "src/**", "next.config.*"] --- # Next.js Conventions This skill consolidates structural and configuration idioms for Next.js App Router projects. Apply alongside `server-component-patterns` (the RSC/Client model) and `sdlc-typescript-patterns` (TS strictness). ## Project layout ``` project-root/ ├── package.json ├── tsconfig.json ├── next.config.{js,mjs,ts} ├── middleware.ts # optional, runs on every matched request ├── app/ # App Router root (modern) │ ├── layout.tsx # ROOT layout (required) — defines │ ├── page.tsx # / route │ ├── loading.tsx # default loading UI │ ├── error.tsx # default error boundary (Client Component) │ ├── not-found.tsx # 404 UI │ ├── globals.css # imported once in root layout │ ├── (marketing)/ # route group (parens hide from URL) │ │ ├── about/page.tsx # /about │ │ └── pricing/page.tsx # /pricing │ ├── (app)/ # another group, possibly with own layout │ │ ├── layout.tsx │ │ ├── dashboard/page.tsx │ │ └── settings/ │ │ ├── layout.tsx │ │ ├── page.tsx # /settings │ │ └── account/page.tsx # /settings/account │ ├── api/ # API routes (Route Handlers) │ │ └── users/route.ts # GET /api/users │ └── _components/ # private folder (excluded from routing) ├── components/ # shared UI components │ ├── ui/ # primitives (Button, Input, etc.) │ └── features/ # feature-specific ├── lib/ # framework-agnostic utilities │ ├── db.ts # ORM client │ ├── auth.ts # auth setup │ └── utils.ts ├── public/ # static assets — served at / ├── styles/ # if not using app/globals.css └── tests/ # or co-located *.test.tsx ``` For Pages Router projects, `pages/` mirrors the older flat structure. Migrate to App Router only when the BA spec asks. ## Special files (App Router) ### `layout.tsx` (required at app root, optional below) ```tsx // app/layout.tsx — root layout import type { Metadata } from 'next'; import './globals.css'; export const metadata: Metadata = { title: 'My App', description: '...', }; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` Nested layouts compose: - `app/(app)/layout.tsx` wraps everything under `(app)/`. - They preserve state across navigation within the same layout. ### `page.tsx` The route's UI. Default-exported. Receives: ```tsx type Props = { params: { id: string }; // dynamic segments searchParams: { [key: string]: string | string[] | undefined }; }; export default async function Page({ params, searchParams }: Props) { const data = await getData(params.id); return
...
; } ``` In Next.js 15+, `params` and `searchParams` are PROMISES — `await params` to get values. ### `loading.tsx` Auto-wraps the segment in a `` boundary. No props — it's just a fallback. ```tsx // app/dashboard/loading.tsx export default function Loading() { return
Loading dashboard...
; } ``` For more granular control, use `` directly inside the page. ### `error.tsx` Error boundary. MUST be a Client Component: ```tsx // app/dashboard/error.tsx 'use client'; export default function Error({ error, reset }: { error: Error; reset: () => void }) { return (

Error: {error.message}

); } ``` Catches errors thrown in the segment's page or its layouts/children. For root-level errors, use `app/global-error.tsx` (also Client). ### `not-found.tsx` Triggered by `notFound()` calls in Server Components or Route Handlers. ```ts // in a page import { notFound } from 'next/navigation'; const user = await db.users.findUnique({ where: { id: params.id } }); if (!user) notFound(); ``` ### `route.ts` (Route Handlers) Backend endpoints. Export verbs: ```ts // app/api/users/route.ts import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; import { db } from '@/lib/db'; export async function GET() { const users = await db.users.findMany(); return NextResponse.json(users); } const CreateUserSchema = z.object({ email: z.string().email(), name: z.string().min(1) }); export async function POST(req: NextRequest) { const body = await req.json(); const parsed = CreateUserSchema.safeParse(body); if (!parsed.success) return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 }); const user = await db.users.create({ data: parsed.data }); return NextResponse.json(user, { status: 201 }); } ``` Receives `req: NextRequest` and (for dynamic routes) `{ params }`. Returns `NextResponse` or anything `Response`-compatible. ## Route groups Folder name in parens — DOESN'T affect URL but groups files: - `app/(marketing)/about/page.tsx` → `/about` - `app/(marketing)/layout.tsx` — applied only to marketing group routes Use to apply different layouts to logical sections of the app. ## Private folders Folders prefixed with `_` are excluded from routing: - `app/_components/` — shared components colocated with the route they belong to but not routable. ## Metadata Static: ```tsx export const metadata: Metadata = { title: 'Dashboard', description: '...', openGraph: { title: 'Dashboard', images: ['/og-image.png'], }, robots: { index: true, follow: true }, }; ``` Dynamic: ```tsx export async function generateMetadata({ params }: Props): Promise { const user = await getUser(params.id); if (!user) return { title: 'Not found' }; return { title: `${user.name} | Dashboard`, description: user.bio, }; } ``` For absolute URLs (OG images, canonical), set `metadataBase` in root layout: ```tsx export const metadata: Metadata = { metadataBase: new URL('https://example.com'), }; ``` ## Image optimization ```tsx import Image from 'next/image'; User profile; // Remote images ...; ``` Configure remote domains in `next.config.js`: ```js module.exports = { images: { remotePatterns: [ { protocol: 'https', hostname: 'cdn.example.com' }, { protocol: 'https', hostname: '*.amazonaws.com' }, ], }, }; ``` NEVER `images.domains: ['*']` — that allows any host as image proxy, a security risk. ## Font optimization ```tsx // app/layout.tsx import { Inter } from 'next/font/google'; const inter = Inter({ subsets: ['latin'], display: 'swap' }); export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` Self-hosted fonts via `next/font/local`. Either way, fonts are inlined and self-hosted automatically — no external requests. ## `next.config.js` Common patterns: ```js /** @type {import('next').NextConfig} */ module.exports = { reactStrictMode: true, images: { remotePatterns: [...], }, async headers() { return [ { source: '/:path*', headers: [ { key: 'X-Frame-Options', value: 'DENY' }, { key: 'X-Content-Type-Options', value: 'nosniff' }, { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' }, ], }, ]; }, async redirects() { return [{ source: '/old', destination: '/new', permanent: true }]; }, async rewrites() { return [{ source: '/api/legacy/:path*', destination: 'https://legacy.example.com/:path*' }]; }, }; ``` For Next.js 15+ TypeScript config, use `next.config.ts`: ```ts import type { NextConfig } from 'next'; const config: NextConfig = { /* ... */ }; export default config; ``` ## Environment variables | Var pattern | Available in | |---|---| | `NEXT_PUBLIC_*` | Client AND server (bundled into JS) | | Anything else | Server only (Server Components, Route Handlers, Server Actions, middleware) | ```ts // Server-only const dbUrl = process.env.DATABASE_URL; // OK on server, undefined on client // Client-safe const apiUrl = process.env.NEXT_PUBLIC_API_URL; // OK everywhere ``` NEVER prefix a secret with `NEXT_PUBLIC_`. NEVER read `process.env.X` from a Client Component if `X` is a secret. For type safety, declare in `next-env.d.ts` or a custom env validator (e.g., `@t3-oss/env-nextjs`): ```ts // env.ts (project-root) import { createEnv } from '@t3-oss/env-nextjs'; import { z } from 'zod'; export const env = createEnv({ server: { DATABASE_URL: z.string().url(), NEXTAUTH_SECRET: z.string().min(32), }, client: { NEXT_PUBLIC_API_URL: z.string().url(), }, experimental__runtimeEnv: process.env, }); ``` ## Anti-patterns - ❌ Putting `"use client"` at the top of a file as a quick fix when `useState` errors appear — analyze the actual need. - ❌ Reading `process.env.SECRET_KEY` in a Client Component (it's `undefined` AND a security smell). - ❌ `images.domains: ['*']` or omitting `remotePatterns` for remote images. - ❌ Putting heavy dependencies in `middleware.ts` — it runs on every request. - ❌ Using `pages/api/` if the project is App Router (mix routes only when migrating; pick one). - ❌ Skipping `loading.tsx` for slow data — users get blank pages while async work runs. - ❌ Using `` for internal navigation — use `` from `next/link` for client-side routing. - ❌ Disabling `reactStrictMode` to "fix" double-render issues — those are usually real bugs in effects.