From typescript-services
Next.js 14+ App Router patterns: TypeScript strict, RSCs, Server Actions, security headers, performance, accessibility, and testing. Use when building Next.js pages, components, API routes, or configuring App Router projects.
npx claudepluginhub andercore-labs/claudes-kitchen --plugin typescript-servicesThis skill uses the workspace's default tool permissions.
**SCOPE:** App Router (`/app`) only. Pages Router is legacy.
Implements structured self-debugging workflow for AI agent failures: capture errors, diagnose patterns like loops or context overflow, apply contained recoveries, and generate introspection reports.
Monitors deployed URLs for regressions in HTTP status, console errors, performance metrics, content, network, and APIs after deploys, merges, or upgrades.
Provides React and Next.js patterns for component composition, compound components, state management, data fetching, performance optimization, forms, routing, and accessible UIs.
SCOPE: App Router (/app) only. Pages Router is legacy.
RSC (default):
async function Page() {
const data = await fetchData()
return <Component data={data} />
}
Client Component:
'use client'
export function Counter() { const [n, setN] = useState(0) }
Server Action:
'use server'
export async function createUser(prev: State, fd: FormData) {
const parsed = schema.safeParse(Object.fromEntries(fd))
if (!parsed.success) return { errors: parsed.error.flatten().fieldErrors }
await db.user.create({ data: parsed.data })
revalidatePath('/users')
}
Next.js page | component | API route | layout.tsx | next.config.js | RSC vs Client decision
// tsconfig.json
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true
}
}
| Prohibited | Use Instead |
|---|---|
any | unknown + type guard |
interface | type |
enum | const object + union type |
! non-null | optional chaining + narrowing |
as T without guard | type predicate |
| Signal | RSC | Client |
|---|---|---|
| Data fetching / DB / secrets | ✓ | ✗ |
| useState / useEffect / hooks | ✗ | ✓ |
| Event handlers / browser APIs | ✗ | ✓ |
Push 'use client' to leaf nodes. Parent RSC passes data as props.
fetch(url) // static (build)
fetch(url, { next: { revalidate: 60 } }) // ISR
fetch(url, { cache: 'no-store' }) // dynamic
fetch(url, { next: { tags: ['users'] } }) // tagged
revalidateTag('users') / revalidatePath('/users') // on mutation
Parallel fetch (MANDATORY — no waterfall):
const [users, posts] = await Promise.all([getUsers(), getPosts()])
| File | Rule |
|---|---|
error.tsx | 'use client' + reset prop — every segment with async data |
loading.tsx | Skeleton matching content layout |
not-found.tsx | Call notFound() in RSC |
global-error.tsx | Root error boundary wrapping root layout |
| Use Case | Choice |
|---|---|
| Form / RSC mutations | Server Action |
| External webhook / OAuth callback | API Route |
| Public REST endpoint | API Route |
Validation MANDATORY on both (Zod):
// Server Action
const parsed = schema.safeParse(Object.fromEntries(fd))
if (!parsed.success) return { errors: parsed.error.flatten().fieldErrors }
// API Route
const parsed = schema.safeParse(await req.json())
if (!parsed.success) return Response.json({ errors: parsed.error.flatten() }, { status: 400 })
next.config.js)const headers = [
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
{ 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' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
{ key: 'Content-Security-Policy', value: "default-src 'self'; script-src 'self'; frame-ancestors 'none'" }
]
module.exports = { async headers() { return [{ source: '/(.*)', headers }] } }
| Rule | Pattern |
|---|---|
| Validate at startup | t3-env or Zod in env.ts — fail fast |
| Server-only secrets | No NEXT_PUBLIC_ prefix — never in client bundle |
| Safe to expose | NEXT_PUBLIC_API_URL only |
| DB URLs / signing keys | Server env, never NEXT_PUBLIC_ |
// env.ts
export const env = createEnv({
server: { DATABASE_URL: z.string().url(), AUTH_SECRET: z.string().min(32) },
client: { NEXT_PUBLIC_API_URL: z.string().url() },
runtimeEnv: process.env
})
const { success } = await ratelimit.limit(req.headers.get('x-forwarded-for') ?? 'anon')
if (!success) return new Response('Too Many Requests', { status: 429 })
import Image from 'next/image'
<Image src="/hero.jpg" alt="Hero" width={800} height={400} priority />
// ✗ raw <img> — no optimization, causes CLS
import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'], display: 'swap' })
// ✗ CSS @import — causes FOUT
| Metric | Target | Fix |
|---|---|---|
| LCP | < 2.5s | priority on above-fold images |
| CLS | < 0.1 | Explicit width/height on all media |
| INP | < 200ms | Minimize client JS, defer non-critical |
<Suspense fallback={<UserListSkeleton />}>
<UserList /> {/* slow RSC — streams independently */}
</Suspense>
| State Type | Tool | Rule |
|---|---|---|
| Global UI (theme, modals) | Jotai atoms | UI only — no server data |
| Server / async data | TanStack Query | Caching, invalidation, mutations |
| URL / shareable state | nuqs / useSearchParams | Pagination, filters, tabs |
| Form state | React Hook Form | Local, not global |
Violation: Server data in Jotai atom → use TanStack Query.
// Static
export const metadata: Metadata = {
title: 'Page Title',
openGraph: { title: 'Page Title', images: ['/og.png'] },
twitter: { card: 'summary_large_image' }
}
// Dynamic
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const post = await getPost(params.slug)
return { title: post.title, openGraph: { images: [post.image] } }
}
Required in /app:
// sitemap.ts
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
return [{ url: 'https://example.com', lastModified: new Date() }]
}
// robots.ts
export default function robots(): MetadataRoute.Robots {
return { rules: { userAgent: '*', allow: '/' } }
}
// .eslintrc
{ "extends": ["next/core-web-vitals", "plugin:jsx-a11y/recommended"] }
| Rule | Standard |
|---|---|
| Interactive elements | <button> not <div onClick> |
| Images | alt always (alt="" for decorative) |
| Forms | <label htmlFor> or aria-label |
| Focus | Visible :focus-visible ring |
| Colour contrast | 4.5:1 minimum (AA) |
| Keyboard | All interactions reachable via Tab |
| Type | Tool | Scope | Location |
|---|---|---|---|
| Component | Jest + RTL | All components | .test.tsx co-located |
| API routes | Jest | All routes | __tests__/api/ |
| API mocking | MSW | Component + integration | src/mocks/ |
| E2E | Playwright | Critical user flows | e2e/ |
// ✓ Accessible queries
screen.getByRole('button', { name: /submit/i })
screen.getByLabelText('Email')
// ✗ Implementation queries
screen.getByTestId('submit-btn')
container.querySelector('.submit')
Given-When-Then:
it('shows error when email is invalid', async () => {
// Given
render(<LoginForm />)
// When
await userEvent.type(screen.getByLabelText('Email'), 'bad')
await userEvent.click(screen.getByRole('button', { name: /login/i }))
// Then
expect(screen.getByText('Invalid email')).toBeInTheDocument()
})
test('user can log in', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Email').fill('user@example.com')
await page.getByRole('button', { name: /log in/i }).click()
await expect(page).toHaveURL('/dashboard')
})
Error scenarios → component tests, NOT E2E.
/app/[locale]/
layout.tsx | page.tsx | error.tsx | loading.tsx
/dashboard/components/ # page-scoped
/components # shared
/lib # utilities, non-React
/store # Jotai atoms
/hooks # custom hooks
/types # TypeScript types
/mocks # MSW handlers
/e2e # Playwright
| Rule | Standard |
|---|---|
| Files | kebab-case.tsx |
| Components | PascalCase |
| Hooks | camelCase + use prefix |
| Constants | UPPER_SNAKE_CASE |
| Imports | @/ absolute alias — no ../../ |
Barrel exports (index.ts) | PROHIBITED — breaks tree-shaking |
| Cross-feature imports | PROHIBITED — use /lib or /components |
- run: pnpm lint
- run: pnpm tsc --noEmit
- run: pnpm test --coverage
- run: pnpm build
- run: pnpm exec playwright install && pnpm e2e
- run: pnpm audit --audit-level moderate
# Lighthouse CI
- uses: treosh/lighthouse-ci-action@v10
with:
budgetPath: .lighthouserc.json
# .lighthouserc.json
# { "assertions": { "categories:performance": ["error", { "minScore": 0.9 }] } }
CHECK:
1. tsconfig → strict/noUncheckedIndexedAccess/exactOptionalPropertyTypes
2. 'use client' → leaf nodes only, not parent RSCs
3. Parallel fetch → Promise.all, no sequential awaits
4. error.tsx + loading.tsx → every segment with async data
5. Fetch strategy → explicit cache:/revalidate: on all fetches
6. Security headers → next.config.js headers() defined
7. Env vars → t3-env/Zod, NEXT_PUBLIC_ only for safe values
8. Input validation → Zod on ALL Server Actions + API routes
9. next/image → no raw <img> tags
10. next/font → no CSS @import for fonts
11. Metadata → generateMetadata or static metadata on every page
12. sitemap.ts + robots.ts → present in /app
13. jsx-a11y → in ESLint config
14. RTL queries → getByRole/getByLabelText/getByText only
15. Playwright e2e → happy paths covered
16. MSW → API calls mocked in component tests
17. Barrel exports → no index.ts re-exports
18. State routing → Jotai=UI, TanStack=server, nuqs=URL
19. Lighthouse CI → performance budget ≥ 0.9
20. All pass → Generate code
ANY fail → REJECT with violation
| Phase | Action |
|---|---|
| 1. Scan | Identify files in /app, /components, /lib |
| 2. Validate | Run ALL 20 verification checks |
| 3. Report | ✓ ALL pass → Done | ✗ ANY fail → REJECT with violations |
| 4. Fix | Violations → Regenerate → Re-validate |
| 5. Store Metrics | After ALL validation passes → call mcp__agent-orchestrator__store-skill-metrics |
All pass:
{
"discipline": "nextjs",
"timestamp": "ISO8601",
"typescript_strictness": { "violations": [], "status": "pass" },
"app_router": { "violations": [], "status": "pass" },
"security": { "violations": [], "status": "pass" },
"performance": { "violations": [], "status": "pass" },
"accessibility": { "violations": [], "status": "pass" },
"testing": { "violations": [], "status": "pass" },
"summary": { "critical": 0, "errors": 0, "warnings": 0 }
}