Ringkasan Utama
Plugin Claude Code untuk codebase Next.js App Router butuh empat file: satu skill yang mengajarkan batas server dan client, satu hooks.json yang menjalankan type check sekali per tool batch, satu subagent dengan tools terbatas untuk review route metadata, dan satu manifest plugin.json. Pilih event hook-nya dengan hati-hati, atau tim akan mematikannya.
Waktu saya memperbaiki hal yang sama untuk keempat kalinya dalam satu minggu, saya berhenti memperbaikinya dan mulai menuliskannya. Sebuah agent menaruh directive use client di baris pertama sebuah file route supaya satu tombol bisa menyimpan state, dan setiap module yang di-import route itu ikut terkirim ke browser. Type checker diam. Build lolos. Tidak ada yang rusak selain bundle-nya.
Ini plugin yang saya tulis untuk stack itu, utuh: satu manifest, satu skill, satu hook, dan satu subagent, di atas App Router plus TypeScript plus Tailwind. Saya memakainya di situs ini, sebuah repository yang direktori blog component-nya saja berisi lebih dari 550 file. Bagian menariknya bukan keempat file itu, melainkan kenyataan bahwa hanya satu dari empat yang selamat satu minggu kerja tanpa diubah.
Empat hal yang salah dilakukan agent di sini, tanpa diminta
Tidak ada yang aneh di antara keempatnya, dan justru itu masalahnya. Masing-masing adalah langkah yang wajar di codebase React lain, jadi agent memilihnya tanpa perlu diminta.
- Agent menandai file route sebagai file client. Satu halaman butuh satu kontrol interaktif, directive-nya mendarat di baris pertama page.tsx, dan karena use client mendeklarasikan batas module graph, semua yang di-import file itu dan setiap component yang dirender langsung ikut masuk ke client bundle.
- Agent melempar function melewati batas itu. Sebuah Server Component menyerahkan callback onSelect ke leaf client. Dokumentasi Next.js tegas soal ini: props yang dikirim ke Client Component harus serialisable menurut React, dan function bukan salah satunya.
- Agent memanggil useState atau useEffect di file tanpa directive. File itu Server Component secara default, jadi tidak ada hook yang bisa dipanggil dan tidak ada client runtime yang menjalankannya.
- Agent meng-import Link dari next/link. Benar di hampir semua repository Next.js lain, salah di repository ini, karena di sini wrapper navigation dari next-intl yang membawa prefix locale.
Tidak satu pun dari keempatnya berupa type error, dan pembedaan itu akhirnya menentukan seluruh desain plugin-nya. Aturan yang bisa diputuskan mesin tempatnya di hook. Aturan yang butuh pertimbangan harus sudah ada di context agent sebelum file ditulis, dan itu berarti skill.
Skill yang memikul batas itu, karena tsc tidak akan
Next.js sebenarnya punya checker untuk persis kesalahan-kesalahan ini, dan agent tidak pernah menjalankannya. Dokumentasi konfigurasi TypeScript menjelaskan sebuah TypeScript plugin khusus yang memperingatkan nilai route segment config yang tidak valid, memastikan directive use client dipakai dengan benar, dan memastikan client hook seperti useState hanya muncul di Client Component. Statusnya IDE plugin: kamu mengaktifkannya di VS Code dengan memilih workspace TypeScript version, dan ia bekerja lewat language server editor sambil kamu mengetik. Agent yang menulis file lewat tool call tidak punya editor maupun language server.
Halaman yang sama mengarahkan kamu ke tsc --noEmit untuk pemeriksaan sebelum build, dan mencatat bahwa type checking lewat CLI mencetak diagnostics tsc apa adanya tanpa penulisan ulang khas Next.js. Jadi pemeriksaan yang seharusnya menangkap keempat kesalahan itu justru yang tidak ada di loop yang benar-benar dijalankan agent. Aturannya masuk ke skill. Skill adalah markdown yang dimuat model ketika ia menilai description-nya relevan, jadi description-nya ditulis sebagai daftar pemicu, bukan ringkasan: bagian itulah yang menetap di context, sementara isinya baru datang saat dibutuhkan.
# skills/app-router-boundaries/SKILL.md
---
name: app-router-boundaries
description: Rules for the server and client boundary in this Next.js App
Router repo. Use before writing or editing anything under app/, and whenever
a component needs state, an event handler, a browser API or a data fetch.
---
# The boundary in this repo
Layouts and pages are Server Components by default. Add "use client" to the
leaf that actually needs interactivity - never to the route file.
## Wrong: one button turns the whole route into a client bundle
// app/(pages)/orders/page.tsx
"use client"; // every import below now ships to the browser
export default async function Page() { ... }
## Right: fetch on the server, hand serialisable props to a client leaf
// app/(pages)/orders/page.tsx <- no directive, so still a Server Component
import { OrderFilter } from "./order-filter";
export default async function Page() {
const orders = await getOrders(); // the DB URL never leaves this file
return <OrderFilter initial={orders} />;
}
// app/(pages)/orders/order-filter.tsx
"use client";
export function OrderFilter({ initial }: { initial: Order[] }) { ... }
## Three rules that are not type errors
1. Props crossing into a Client Component must be serialisable by React. A
function is not. Pass an id and a server action, never a callback.
2. "use client" is a module-graph boundary: everything the file imports and
every component it renders directly joins the client bundle. Children
passed as props do NOT - they render on the server and arrive as output.
3. React context does not exist in Server Components. A provider is a client
file taking children, rendered as deep in the tree as it will go.
## Local conventions that override the generic advice
- Import Link, useRouter and usePathname from @/i18n/navigation. next/link
is correct in most Next.js repos and wrong in this one: it drops the
locale prefix, so the route 404s in one locale only.
- Any lib/ module reading process.env without a NEXT_PUBLIC_ prefix starts
with import "server-only", so a stray client import fails the build
instead of silently shipping an empty string.Pasangan wrong-and-right bekerja lebih keras daripada semua prosa di sekitarnya. Aturan yang dinyatakan abstrak akan diparafrase jadi sesuatu yang sekadar mirip; aturan yang dinyatakan sebagai dua file bernama, salah satunya diberi label salah, akan disalin. Sebagian besar nilai file itu ada di dua belas baris tersebut.
Isi plugin-nya, dalam bentuk file
Empat komponen, empat direktori, dan satu aturan soal letaknya: hanya plugin.json yang tinggal di dalam .claude-plugin. Direktori skills, agents, dan hooks semuanya berada di root plugin, dan dokumentasi plugin menyebut menaruhnya di dalam .claude-plugin sebagai kesalahan yang umum. Memang begitu, dan saya yang pertama melakukannya.
nextjs-stack-toolkit/
├── .claude-plugin/
│ └── plugin.json # the ONLY file that belongs in here
├── skills/
│ └── app-router-boundaries/
│ └── SKILL.md # loads when the model judges it relevant
├── agents/
│ └── route-metadata-reviewer.md # own context window, read-only tools
├── hooks/
│ ├── hooks.json # PostToolBatch, not PostToolUse
│ └── typecheck.sh
└── README.md
# .claude-plugin/plugin.json - "name" is the only required field
{
"name": "nextjs-stack-toolkit",
"displayName": "Next.js Stack Toolkit",
"description": "App Router boundary rules, a batch typecheck gate, and a route metadata reviewer",
"version": "0.4.0",
"author": { "name": "Matthews Wong", "url": "https://www.matthewswong.com" },
"license": "MIT",
"keywords": ["nextjs", "app-router", "typescript", "tailwind"]
}name adalah satu-satunya field wajib di manifest, dan sekaligus namespace-nya: skill-nya dipanggil dengan nama ber-namespace, nextjs-stack-toolkit:app-router-boundaries, dan subagent-nya diselesaikan dengan cara yang sama. version adalah tombol update. Kalau diisi, orang yang meng-install baru menerima perubahan ketika kamu menaikkan angkanya, jadi field itu bukan hiasan. Plugin yang mengirim tepat satu skill boleh menaruh SKILL.md di root plugin dan melewatkan manifest sama sekali; punya saya berisi empat komponen, jadi ia memakai layout lengkap.
Hook yang jalan di setiap write adalah hook yang dimatikan orang
Satu putaran penuh tsc --noEmit di repository ini butuh sekitar tiga setengah detik dalam kondisi warm. Saya mengukur tiga run berurutan pada 3,83, 3,47, dan 3,52 detik wall clock, di proyek yang saat saya ukur berisi 556 file di direktori blog component-nya saja. Harga itu wajar kalau dibayar sekali. Tidak wajar kalau dibayar per write, dan per write itulah biaya PostToolUse: referensi hooks menyatakan PostToolUse jalan sekali per tool, yang berarti ia jalan bersamaan ketika Claude memanggil tool secara paralel. Lima Edit paralel berarti lima proses tsc di satu tree pada waktu yang sama.
Saya mencoba perbaikan yang paling jelas lebih dulu, dan hasilnya malah lebih buruk. Menambahkan --incremental dengan tsBuildInfoFile butuh 35,2 detik pada run pertama, karena ia harus menulis build info seluruh proyek, lalu 4,19 detik pada setiap run berikutnya: lebih lambat daripada perintah biasa, karena sekarang ia juga membaca dan menulis ulang file itu. Tidak ada keuntungan incremental yang murah di tsconfig satu proyek yang bukan composite.
Yang benar-benar memperbaikinya adalah nama event-nya, bukan perintahnya. PostToolBatch jalan sekali setelah semua tool call dalam satu batch selesai, sebelum request berikutnya dikirim ke model, dan berbeda dari PostToolUse ia termasuk event yang bisa memblokir: exit code 2 menghentikan agentic loop. Satu run per batch, bukan satu per file, di titik terakhir yang masih bisa menghemat satu turn.
// hooks/hooks.json
{
"hooks": {
"PostToolBatch": [
{
"hooks": [
{
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/typecheck.sh",
"timeout": 60
}
]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit",
"if": "Edit(app/**)",
"hooks": [
{
"type": "command",
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/boundary-lint.sh",
"timeout": 15
}
]
}
]
}
}Entry kedua adalah yang murah, dan ia bertahan berkat field if, yang memuat satu permission rule dan dievaluasi sebelum script-nya di-spawn, jadi satu batch berisi panggilan Bash tidak membayar biaya spawn proses sama sekali. Entry itu juga yang akhirnya saya buang, karena alasan yang baru muncul setelah dipakai.
#!/usr/bin/env bash
# hooks/typecheck.sh
set -euo pipefail
payload=$(cat)
# PostToolBatch has no matcher, so it fires on read-only batches too. Decide
# from tool_calls whether this batch wrote any TypeScript at all, before
# spending a single second on tsc.
wrote=$(printf '%s' "$payload" | jq '
[ .tool_calls[]?
| select(.tool_name == "Write" or .tool_name == "Edit")
| .tool_input.file_path // ""
| select(endswith(".ts") or endswith(".tsx"))
] | length')
[ "$wrote" -eq 0 ] && exit 0
cd "${CLAUDE_PROJECT_DIR:-.}"
# There is no useful single-file mode: the path aliases and the generated
# .next/types both come from tsconfig, so it is the whole project or nothing.
if diagnostics=$(npx tsc --noEmit 2>&1); then
exit 0
fi
# PostToolBatch is one of the events where blocking works - it stops the
# agentic loop before the next model call, so the agent never writes a
# second file on top of a tree that does not compile.
jq -n --arg d "$diagnostics" \
'{ decision: "block", reason: ("tsc --noEmit failed: " + $d) }'Field if memuat tepat satu permission rule. Tidak ada and, tidak ada or, dan tidak ada sintaks daftar, jadi menutup .ts sekaligus .tsx berarti dua hook handler yang menunjuk ke perintah yang sama, bukan satu pola yang pintar. Perhatikan juga kedalamannya: pola satu segmen seperti Edit(app/**) hanya cocok untuk direktori app di working directory, jadi sebuah package di monorepo butuh Edit(**/app/**).

Subagent untuk urusan metadata, dan field yang diabaikannya
Pekerjaan berulang yang ingin saya keluarkan dari thread utama adalah review SEO tingkat route: buka satu route, telusuri layout induknya, periksa generateMetadata, lapor. Pekerjaannya banyak membaca, mekanis, dan butuh context yang bersih, karena melakukannya di session utama berarti menarik setengah lusin file layout ke dalam window yang sedang dipakai untuk hal lain. Subagent mendapat context window sendiri, dan menurut referensi subagents ia hanya menerima system prompt di file-nya sendiri plus detail environment dasar, bukan system prompt Claude Code. tools bersifat allowlist, jadi subagent yang dibatasi seperti ini tidak bisa mengedit file, menulis file, atau menjangkau tool MCP apa pun.
# agents/route-metadata-reviewer.md
---
name: route-metadata-reviewer
description: Reviews generateMetadata, canonical URLs, hreflang and JSON-LD
for one App Router route. Use after adding or changing a page, a layout or
a route group. Reports findings; never edits.
tools: Read, Grep, Glob
model: sonnet
maxTurns: 12
skills:
- app-router-boundaries
---
You review route-level SEO for one App Router route at a time. You never edit
a file. You return a findings list ordered by severity, and nothing else.
Read, in this order:
1. The route's own page.tsx and layout.tsx.
2. Every parent layout up to app/[locale]/layout.tsx. A parent's
title: { absolute: ... } cancels the root %s template for every child
route, and that is invisible from the child file alone.
3. lib/seo.ts, for the canonical and hreflang builders this repo already has.
Check exactly these, and report nothing else:
- generateMetadata exists, is async, and awaits params before reading them.
- title is 63 characters or fewer; description is between 140 and 155.
- canonical comes from buildCanonical, not a hand-joined template string.
- languageAlternates covers en and id, both absolute.
- openGraph.images points at a file that exists under public/.
- No duplicate JSON-LD. The blog layout already injects TechArticle,
BreadcrumbList and FAQPage, so a page must not add its own.
For each finding, quote the file and the line, say what a crawler will do
with it, and stop. Do not propose a diff.Field skills adalah bagian yang tidak akan saya lepas. Ia memuat isi lengkap skill batas itu, bukan hanya description-nya, sehingga reviewer membaca sebuah route dengan aturan yang sama seperti yang dipakai penulisnya. Tanpa itu reviewer menciptakan standarnya sendiri, dan dua komponen dalam satu plugin yang berbeda pendapat soal konvensi lebih buruk daripada tidak mengirim keduanya.
Tiga field frontmatter subagent diabaikan untuk subagent yang datang dari plugin: permissionMode, mcpServers, dan hooks. Definisi yang terasa aman ter-sandbox selama ia tinggal di direktori agents sebuah proyek berubah sifat begitu file yang sama dikirim di dalam plugin, karena permission mode-nya dibuang dan hanya daftar tools yang tersisa sebagai pembatas. Batasi dengan tools dan disallowedTools, dan anggap permission mode session-lah yang berlaku.
Mana dari keempatnya yang layak bertahan
Setelah beberapa minggu, keputusannya bukan yang saya perkirakan saat menulis manifest. Komponen yang paling sering saya edit justru yang tidak berisi kode, dan komponen yang saya hapus justru yang paling terlihat seperti pekerjaan engineering.
| Komponen | Keputusan | Apa yang menentukannya |
|---|---|---|
| Skill batas server dan client | Dipertahankan, dua kali ditulis ulang | Satu-satunya lapisan yang bisa mencegah, bukan cuma melaporkan |
| Type check di PostToolBatch | Dipertahankan, setelah ganti event | Tiga setengah detik sekali per batch tak terasa; per write terasa |
| Subagent metadata | Dipertahankan, dipakai dua kali seminggu | Tidak berbiaya saat idle, jadi frekuensi bukan ukuran yang tepat |
| Boundary lint di PostToolUse | Dibuang | Jalan di tengah edit, pada file yang sedang diperbaiki agent |
Yang saya buang punya tugas yang benar dengan waktu yang salah. Meng-grep file yang baru ditulis untuk mencari client hook di file tanpa directive adalah pemeriksaan yang benar, tapi agent yang sedang membuat leaf client sering menulis isinya dulu dan directive-nya kemudian, jadi handler-nya jalan pada file yang dua detik lagi sudah benar dan model menghabiskan satu turn membela pekerjaan yang sedang ia selesaikan. Semua yang ia tangkap juga tertangkap pemeriksaan tingkat batch, satu model call lebih lambat dan dengan seluruh batch terlihat. Pertanyaan di belakang keempat keputusan itu ternyata sama: di titik mana dalam loop ini hal tersebut masih bisa mengubah hasil?

Validasi dulu sebelum orang lain meng-install-nya
Plugin adalah dependency yang di-install orang lain, jadi hal terakhir yang ditulis bukan sebuah fitur. Empat langkah, dengan urutan ini, setiap kali ada yang berubah di dalamnya.
- Muat untuk satu session tanpa meng-install apa pun: claude --plugin-dir ./nextjs-stack-toolkit. Flag ini menerima direktori atau arsip .zip, dan bisa diulang untuk memuat satu plugin bersama plugin yang menjadi dependency-nya.
- Picu event setiap hook dengan sengaja, dengan meminta agent mengedit sebuah file TypeScript, lalu baca debug log-nya, yang mencatat hook mana yang cocok, bagaimana ia keluar, dan apa yang dicetaknya. Hook yang diam-diam tidak pernah cocok terlihat persis seperti hook yang lolos.
- Jalankan claude plugin validate ./nextjs-stack-toolkit. Ia melaporkan field manifest yang tidak dikenal sebagai warning, bukan error, jadi tambahkan --strict kalau kamu ingin salah tulis nama field menggagalkan pemeriksaan.
- Naikkan version di plugin.json. Kalau tidak, orang yang meng-install tetap memakai salinan yang sudah ia punya dan perbaikanmu tidak sampai ke siapa pun.
Ada satu ketidaksimetrisan di loop pengembangan yang sebaiknya kamu tahu sebelum kehilangan satu sore karenanya. Perubahan pada SKILL.md sebuah skill langsung berlaku di session yang sedang jalan, tapi perubahan pada hooks, agents, dan konfigurasi MCP plugin tidak. Jalankan perintah reload-plugins setelah menyentuh yang tiga itu, atau sore itu habis untuk men-debug versi hook yang lama.
Aturan yang saya bawa dari sini: taruh sebuah aturan di hook hanya kalau mesin bisa memutuskannya, taruh di skill kalau hanya pertimbangan yang bisa, dan pilih event hook-nya dengan bertanya kapan jawabannya masih bisa mengubah sesuatu. Empat file, satu di antaranya sudah dihapus, dan bagian tersulitnya adalah nama event.
Sumber
- Dokumentasi Claude Code — Plugins reference: skema manifest, layout direktori, dan variabel root plugin
- Dokumentasi Claude Code — Hooks: daftar event, PostToolUse versus PostToolBatch, field if, dan timeout
- Dokumentasi Claude Code — Subagents: field frontmatter, pembatasan tool, dan apa yang diabaikan subagent plugin
- Dokumentasi Next.js — Server and Client Components: batas use client dan props yang serialisable
- Dokumentasi Next.js — TypeScript: apa yang diperiksa IDE plugin, dan tsc --noEmit



