Plugin Claude Code untuk Next.js: Skill, Hook, Subagent

Foto oleh Roy Egloff via Wikimedia Commons (CC BY-SA 4.0)
Empat hal sudah menutup hampir semua kebutuhan: satu skill di skills/ yang memuat konvensi App Router yang tidak bisa disimpulkan agent, satu hooks/hooks.json yang menjalankan type check setelah write, satu subagent di agents/ untuk satu pekerjaan review yang sempit dan berulang, serta manifest .claude-plugin/plugin.json. Hanya plugin.json yang tinggal di dalam .claude-plugin; direktori lainnya berada di root plugin. Field name di manifest adalah satu-satunya yang wajib, dan ia sekaligus menjadi namespace bagi skill dan subagent yang kamu kirim.
PostToolBatch, hampir pada semua kasus. PostToolUse jalan sekali per tool dan jalan bersamaan ketika Claude memanggil tool secara paralel, jadi satu batch berisi lima edit memicu lima proses type check di tree yang sama. PostToolBatch jalan sekali setelah seluruh batch selesai dan sebelum model call berikutnya, dan ia termasuk event yang exit code 2-nya benar-benar memblokir agentic loop.
Karena pemeriksaan itu ada di tool yang berbeda. Next.js menyertakan TypeScript plugin khusus yang memastikan directive use client dipakai dengan benar dan client hook hanya muncul di Client Component, tapi ia berjalan di editor melalui TypeScript language server. Menjalankan tsc dari CLI mencetak diagnostics TypeScript apa adanya tanpa pemeriksaan khas Next.js itu, jadi agent yang menulis file lewat tool call tidak pernah melihatnya. Itulah sebabnya aturan batas server dan client tempatnya di skill, bukan di hook.
Pakai field tools sebagai allowlist, atau disallowedTools sebagai denylist, di frontmatter subagent-nya. Subagent yang daftar tools-nya hanya Read, Grep, dan Glob tidak bisa mengedit file, menulis file, atau menjangkau tool MCP apa pun. Perlu diingat bahwa permissionMode, mcpServers, dan hooks di frontmatter subagent diabaikan untuk subagent yang datang dari plugin, jadi definisi yang tampak ter-sandbox sebagai file proyek kehilangan permission mode-nya begitu dikirim di dalam plugin.
Jalankan Claude Code dengan claude --plugin-dir ./plugin-kamu, yang memuat plugin itu untuk session tersebut tanpa meng-install apa pun. Flag ini menerima direktori atau arsip .zip dan bisa diulang untuk beberapa plugin. Jalankan claude plugin validate ./plugin-kamu untuk memeriksa manifest-nya, tambahkan --strict kalau kamu ingin warning seperti nama field yang tidak dikenal ikut menggagalkan pemeriksaan, dan ingat bahwa perubahan skill berlaku langsung sementara perubahan hook dan subagent butuh reload plugin.

Foto oleh Roy Egloff via Wikimedia Commons (CC BY-SA 4.0)
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.
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.
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.
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.
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.
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/**).

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.
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?

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.
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