start-vibing-stacks 2.18.0 → 2.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,42 +1,97 @@
1
1
  ---
2
2
  name: zod-validation
3
- version: 1.0.0
3
+ version: 2.0.0
4
+ description: "Zod 4 (stable Aug 2025) runtime validation for TypeScript boundaries — forms, API responses, env vars, server actions. Zod 4 brings 14× faster string parsing, 7× array, 6.5× object, 57% smaller bundle, 2× faster TS compile, 100× fewer tsc instantiations. Major API shifts: top-level format validators (`z.email()`, `z.url()`, `z.uuid()`) for tree-shaking; unified `error` parameter replaces `required_error` / `invalid_type_error` / `errorMap`. Codemod available: `npx @zod/codemod`. Covers schema design, RHF + zodResolver integration, API response validation, split server/client env schemas (security-critical for `NEXT_PUBLIC_*`), reusable schemas, transforms, and Next.js Server Actions. Mentions Valibot/ArkType when bundle/inference cost matters. Invoke at any trust boundary."
4
5
  ---
5
6
 
6
- # Zod Validation — Runtime Type Safety
7
+ # Zod 4 — Runtime Type Safety (2026)
7
8
 
8
- **ALWAYS use Zod for form validation, API responses, and data boundaries.**
9
+ **ALWAYS use Zod 4 for form validation, API responses, env vars, server actions.**
9
10
 
10
- ## Why Zod
11
+ ## Why Zod 4 (stable since August 2025)
11
12
 
12
- - Runtime validation (TypeScript types disappear at runtime)
13
- - Auto-infer TypeScript types from schemas
14
- - Works with React Hook Form, tRPC, Next.js Server Actions
15
- - Single source of truth: schema type validation
13
+ - **14× faster** string parsing, **7×** array, **6.5×** object
14
+ - **57% smaller** core bundle
15
+ - **~2× faster** TS compilation on large schemas; **100× fewer** tsc instantiations
16
+ - Ecosystem locked in: tRPC, Drizzle, React Hook Form, OpenAPI generators
17
+ - 15M+ weekly downloads — the de-facto standard
18
+
19
+ ### When to consider an alternative
20
+
21
+ | Situation | Tool |
22
+ |---|---|
23
+ | **Bundle size** is critical (edge functions, embeds) | **Valibot** — sub-1KB simple schemas |
24
+ | You want **faster TS check** in giant codebases | **Valibot** (~18× faster type-check vs Zod 4) |
25
+ | You like TS-syntax string schemas | **ArkType** |
26
+ | Mainstream ecosystem + maturity | **Zod 4** ✅ |
27
+
28
+ ## Migration v3 → v4
29
+
30
+ ```bash
31
+ npx @zod/codemod --transform v3-to-v4 ./src
32
+ ```
33
+
34
+ The two breaking changes you'll see most:
35
+
36
+ ### 1. Top-level format validators (tree-shaking)
37
+
38
+ ```tsx
39
+ // v3 (works in v4 but not tree-shakable)
40
+ z.string().email().min(5);
41
+
42
+ // v4 — recommended
43
+ z.email();
44
+ z.url();
45
+ z.uuid();
46
+ z.iso.datetime(); // ISO-8601
47
+ z.iso.date();
48
+ z.cuid2();
49
+ z.ipv4();
50
+ z.ipv6();
51
+ z.base64();
52
+ z.jwt();
53
+ ```
54
+
55
+ ### 2. Unified `error` parameter
56
+
57
+ ```tsx
58
+ // v3 — three different parameters
59
+ z.string({
60
+ required_error: "Required",
61
+ invalid_type_error: "Must be a string",
62
+ errorMap: ({ code }) => ({ message: code === "too_small" ? "Too short" : "Invalid" }),
63
+ });
64
+
65
+ // v4 — one parameter, string or function
66
+ z.string({
67
+ error: (issue) => issue.code === "too_small" ? "Too short" : "Required",
68
+ });
69
+ // Or just a string:
70
+ z.string({ error: "Name is required" });
71
+ ```
16
72
 
17
73
  ## Core Patterns
18
74
 
19
- ### Schema Definition
75
+ ### Schema definition
20
76
 
21
77
  ```tsx
22
78
  import { z } from 'zod';
23
79
 
24
- // Define schema
25
80
  const UserSchema = z.object({
26
- name: z.string().min(2, 'Name must be at least 2 characters'),
27
- email: z.string().email('Invalid email address'),
28
- age: z.number().min(18, 'Must be 18+').max(120),
29
- role: z.enum(['admin', 'user', 'moderator']),
30
- bio: z.string().max(500).optional(),
31
- tags: z.array(z.string()).min(1, 'At least one tag required'),
81
+ name: z.string().min(2, "Name must be at least 2 characters"),
82
+ email: z.email({ error: "Enter a valid email" }), // v4 top-level
83
+ age: z.number().min(18, "Must be 18+").max(120),
84
+ role: z.enum(["admin", "user", "moderator"]),
85
+ bio: z.string().max(500).optional(),
86
+ tags: z.array(z.string()).min(1, "At least one tag required"),
32
87
  metadata: z.record(z.string(), z.unknown()).optional(),
33
88
  });
34
89
 
35
- // Infer type from schema (SINGLE SOURCE OF TRUTH)
90
+ // SINGLE SOURCE OF TRUTH schema type
36
91
  type User = z.infer<typeof UserSchema>;
37
92
  ```
38
93
 
39
- ### Form Validation (React Hook Form + Zod)
94
+ ### Form Validation (React Hook Form + Zod 4)
40
95
 
41
96
  ```tsx
42
97
  import { useForm } from 'react-hook-form';
@@ -44,7 +99,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
44
99
 
45
100
  const CreateUserSchema = z.object({
46
101
  name: z.string().min(2),
47
- email: z.string().email(),
102
+ email: z.email(), // v4 top-level
48
103
  password: z.string().min(8, 'Password must be at least 8 characters'),
49
104
  confirmPassword: z.string(),
50
105
  }).refine((data) => data.password === data.confirmPassword, {
@@ -150,24 +205,47 @@ export const clientEnv = ClientEnvSchema.parse({
150
205
 
151
206
  **Rule:** If a variable contains a key, secret, token, or password, it MUST be in `ServerEnvSchema` without `NEXT_PUBLIC_` prefix.
152
207
 
153
- ### Reusable Schemas
208
+ ### Reusable Schemas (Zod 4 top-level)
154
209
 
155
210
  ```tsx
156
- // Shared between frontend and backend
157
- // schemas/user.ts
158
-
159
- export const EmailSchema = z.string().email().toLowerCase().trim();
211
+ // schemas/common.ts — shared between frontend and backend
212
+ export const EmailSchema = z.email().transform(v => v.toLowerCase().trim());
160
213
  export const PasswordSchema = z.string().min(8).max(128);
161
- export const UUIDSchema = z.string().uuid();
214
+ export const UUIDSchema = z.uuid();
215
+ export const URLSchema = z.url();
216
+ export const ISODateSchema = z.iso.datetime();
217
+
162
218
  export const PaginationSchema = z.object({
163
- page: z.coerce.number().min(1).default(1),
219
+ page: z.coerce.number().min(1).default(1),
164
220
  limit: z.coerce.number().min(1).max(100).default(20),
165
221
  });
166
222
 
167
223
  export const DateRangeSchema = z.object({
168
224
  from: z.coerce.date(),
169
- to: z.coerce.date(),
170
- }).refine((d) => d.to > d.from, 'End date must be after start date');
225
+ to: z.coerce.date(),
226
+ }).refine(d => d.to > d.from, "End date must be after start date");
227
+ ```
228
+
229
+ ### Discriminated unions (polymorphic payloads)
230
+
231
+ ```tsx
232
+ const PaymentSchema = z.discriminatedUnion("type", [
233
+ z.object({ type: z.literal("card"), last4: z.string().length(4) }),
234
+ z.object({ type: z.literal("pix"), key: z.string() }),
235
+ z.object({ type: z.literal("boleto"), barcode: z.string() }),
236
+ ]);
237
+ type Payment = z.infer<typeof PaymentSchema>;
238
+ ```
239
+
240
+ ### Branded types — distinguish IDs at the type level
241
+
242
+ ```tsx
243
+ const UserIdSchema = z.uuid().brand<"UserId">();
244
+ type UserId = z.infer<typeof UserIdSchema>;
245
+
246
+ function getUser(id: UserId) { /* … */ }
247
+ getUser("not a uuid"); // ❌ type error
248
+ getUser(UserIdSchema.parse(input)); // ✅
171
249
  ```
172
250
 
173
251
  ### Transform & Preprocess
@@ -253,20 +331,64 @@ export function LeadForm() {
253
331
  }
254
332
  ```
255
333
 
334
+ ## Use with React 19 Actions + `useActionState`
335
+
336
+ ```tsx
337
+ "use client";
338
+ import { useActionState } from "react";
339
+ import { z } from "zod";
340
+
341
+ const Schema = z.object({
342
+ email: z.email(),
343
+ password: z.string().min(8),
344
+ });
345
+
346
+ async function loginAction(_prev: { error: string | null }, formData: FormData) {
347
+ const parsed = Schema.safeParse(Object.fromEntries(formData));
348
+ if (!parsed.success) return { error: parsed.error.issues[0].message };
349
+ return await loginRequest(parsed.data);
350
+ }
351
+
352
+ export function LoginForm() {
353
+ const [state, action, pending] = useActionState(loginAction, { error: null });
354
+ return (
355
+ <form action={action}>
356
+ <input name="email" type="email" />
357
+ <input name="password" type="password" />
358
+ {state.error && <p className="text-destructive">{state.error}</p>}
359
+ <button disabled={pending}>{pending ? "Signing in…" : "Sign in"}</button>
360
+ </form>
361
+ );
362
+ }
363
+ ```
364
+
256
365
  ## FORBIDDEN
257
366
 
258
367
  | Don't | Do |
259
368
  |---|---|
260
- | Trust API responses blindly | `Schema.parse(response)` |
261
- | Manual `if/else` validation | Zod schema |
369
+ | Trust API responses blindly | `Schema.parse(response)` (or `safeParse` for soft fail) |
370
+ | Manual `if/else` validation | Zod schema (single source of truth) |
262
371
  | Duplicate types + validation | `z.infer<typeof Schema>` |
263
372
  | `any` or `unknown` without validation | Parse with Zod first |
264
- | Validate only on server | Validate on BOTH client + server |
373
+ | Validate only on server | Validate on BOTH client and server |
374
+ | `z.string().email()` in v4 | `z.email()` (tree-shakable, top-level) |
375
+ | `required_error` / `invalid_type_error` / `errorMap` (v3) | Unified `error` parameter (v4) |
376
+ | Dump secrets into `NEXT_PUBLIC_*` | Server-only `ServerEnvSchema`; client gets only public keys |
377
+ | Re-implement common formats (URL, UUID, datetime, JWT) | `z.url()`, `z.uuid()`, `z.iso.datetime()`, `z.jwt()` |
265
378
 
266
379
  ## Rules
267
380
 
268
381
  1. **SINGLE SOURCE OF TRUTH** — schema defines type AND validation
269
- 2. **VALIDATE AT BOUNDARIES** — forms, API responses, env vars
270
- 3. **SAFE PARSE FOR UI** — `safeParse()` for user-facing, `parse()` for internal
271
- 4. **SHARED SCHEMAS** — same schema on frontend and backend
272
- 5. **TRANSFORM DATA** — clean/normalize during validation, not after
382
+ 2. **VALIDATE AT BOUNDARIES** — forms, API responses, env vars, queue messages
383
+ 3. **SAFE PARSE FOR UI** — `safeParse()` for user-facing forms, `parse()` for internal/trusted
384
+ 4. **SHARED SCHEMAS** — same schema on frontend and backend (workspace package)
385
+ 5. **TRANSFORM DURING VALIDATION** — `.toLowerCase()`, `.trim()`, `.transform(...)` happen as part of `parse`, not afterwards
386
+ 6. **PREFER TOP-LEVEL FORMAT VALIDATORS** in v4 — better tree-shaking + clearer intent
387
+
388
+ ## See Also
389
+
390
+ - `react-patterns` v2 — `useActionState`, `useOptimistic`, `use()` hook
391
+ - `react-ui-patterns` v2 — RHF + Zod + TanStack Query patterns
392
+ - `shadcn-ui` v2 — `<Form>` component built on RHF + Zod
393
+ - `_shared/skills/security-baseline` v2 — input validation as defense layer
394
+ - `_shared/skills/openapi-design` v2 — schemas → OpenAPI 3.2
@@ -1,11 +1,7 @@
1
1
  ---
2
2
  name: axios-laravel-api
3
- version: 1.0.0
4
- description: Axios HTTP client for Laravel 12 + Sanctum SPA `withCredentials`,
5
- `withXSRFToken`, `/sanctum/csrf-cookie` flow, and `401/403/419/422/5xx`
6
- interceptors. Use when wiring a React (or any SPA) frontend to a Laravel
7
- cookie-authenticated JSON API. Pairs with `laravel-api-architecture` and
8
- `react-api-standards`.
3
+ version: 2.0.0
4
+ description: "Axios HTTP client for Laravel 12 + Sanctum SPA in React 19 (Tailwind v4) projects. `withCredentials: true` + `withXSRFToken: true` + `/sanctum/csrf-cookie` priming flow, plus a single response interceptor that handles 401 (logout), 403 (forbidden toast), 419 (CSRF stale → re-prime + retry once), 422 (validation errors surfaced to RHF), 5xx (toast + log). Pairs with `laravel-api-architecture` (backend) and `react-api-standards` (frontend conventions). 2026 polish: Zod 4 response validation at the boundary, TanStack Query v5 `signal` threading for cancellation, React 19 forms via `useActionState` for client-side mutations. Invoke when wiring or auditing the API client, login flow, or CSRF/cookie configuration."
9
5
  ---
10
6
 
11
7
  # Axios + Laravel Sanctum SPA Client
@@ -1,25 +1,23 @@
1
1
  ---
2
2
  name: react-api-standards
3
- version: 1.0.0
4
- description: React 19 + Vite + Axios standards for a Laravel-backed JSON API SPA
5
- — Page shell + skeleton, async data via `api.get`, form 422 binding, route
6
- catch-all, no Inertia. Use for any new React page in a Laravel + Sanctum SPA
7
- project. Pairs with `axios-laravel-api` and `laravel-api-architecture`.
3
+ version: 2.0.0
4
+ description: "React 19 + Vite 6 + Axios standards for a Laravel-backed JSON API SPA. Page shell + skeleton-first paint, async data via `api.get`, 422 validation binding to React Hook Form, protected-route + catch-all routing, NO Inertia. Pairs with `axios-laravel-api` (HTTP client) and `laravel-api-architecture` (backend). 2026 polish: TanStack Query v5 (`isPending` semantics + `signal` threading), Zod 4 (`z.email()`/`z.url()` top-level), `useOptimistic` for instant feedback, `useActionState` for form submissions, `useFormStatus` to wire Submit buttons without prop drilling. Tailwind v4 with `@theme` and OKLCH tokens. Invoke for any new page, layout, or component in a Laravel + Sanctum SPA."
8
5
  ---
9
6
 
10
- # React 19 API-First Standards (Laravel + Axios)
7
+ # React 19 API-First Standards (Laravel + Axios) — 2026
11
8
 
12
9
  **ALWAYS invoke when creating React pages, components, or layouts in a project
13
10
  that uses a Laravel JSON API as backend (NOT Inertia.js).**
14
11
 
15
12
  ## Version Requirements
16
13
 
17
- - **React >= 19** — MANDATORY (`useActionState`, `useOptimistic`, `use`)
18
- - **Vite >= 5** with `@vitejs/plugin-react`
19
- - **TailwindCSS >= 4**
20
- - **Axios >= 1.6** (for `withXSRFToken` support)
21
- - **TanStack Query >= 5** (recommended for cached lists/details)
22
- - **react-router-dom >= 6** (client-side routing)
14
+ - **React 19.0** — MANDATORY (`useActionState`, `useOptimistic`, `useFormStatus`, `use()`)
15
+ - **Vite 6** with `@vitejs/plugin-react`
16
+ - **TailwindCSS 4** (CSS-first `@theme`, Oxide engine)
17
+ - **Axios 1.7** (for `withXSRFToken` and modern interceptor types)
18
+ - **TanStack Query 5** — `isPending` (no data yet) vs `isFetching` (any in-flight); thread `signal` through `queryFn`
19
+ - **Zod 4** — top-level `z.email()`/`z.url()` for tree-shaking
20
+ - **react-router-dom ≥ 7** (`<RouterProvider>` + data routers)
23
21
 
24
22
  ## Folder Structure
25
23
 
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * check-route-slugs.mjs
4
+ *
5
+ * Static validator for Next.js App Router dynamic-segment naming.
6
+ *
7
+ * Catches the "You cannot use different slug names for the same dynamic path"
8
+ * runtime error BEFORE it ships, because `next build` does not catch it.
9
+ *
10
+ * Rule: inside the same parent directory, every bracket-named child
11
+ * (`[id]`, `[...slug]`, `[[...slug]]`) MUST share the same inner identifier.
12
+ *
13
+ * Examples:
14
+ *
15
+ * OK app/users/[userId]/page.tsx
16
+ * app/users/[userId]/posts/page.tsx
17
+ *
18
+ * FAIL app/users/[id]/page.tsx
19
+ * app/users/[userId]/posts/page.tsx <-- different slug, same parent
20
+ *
21
+ * Usage:
22
+ * node scripts/check-route-slugs.mjs # auto-detect ./app and ./src/app
23
+ * node scripts/check-route-slugs.mjs ./app # explicit root(s)
24
+ *
25
+ * Exit codes:
26
+ * 0 OK
27
+ * 1 conflicting slug names detected
28
+ * 2 no app directory found (skipped, not an error in non-Next.js repos)
29
+ */
30
+
31
+ import { readdir, stat } from 'node:fs/promises';
32
+ import { existsSync } from 'node:fs';
33
+ import { resolve, join, relative } from 'node:path';
34
+
35
+ const BRACKET_RE = /^\[\[?\.\.\.?([A-Za-z0-9_]+)\]?\]$|^\[([A-Za-z0-9_]+)\]$/;
36
+
37
+ function extractSlugName(dirName) {
38
+ const m = dirName.match(BRACKET_RE);
39
+ if (!m) return null;
40
+ return m[1] ?? m[2] ?? null;
41
+ }
42
+
43
+ async function listChildDirs(parent) {
44
+ const entries = await readdir(parent, { withFileTypes: true });
45
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
46
+ }
47
+
48
+ async function walk(root, conflicts) {
49
+ const children = await listChildDirs(root);
50
+
51
+ const bracketChildren = children
52
+ .map((name) => ({ name, slug: extractSlugName(name) }))
53
+ .filter((c) => c.slug !== null);
54
+
55
+ if (bracketChildren.length > 1) {
56
+ const slugs = new Set(bracketChildren.map((c) => c.slug));
57
+ if (slugs.size > 1) {
58
+ conflicts.push({
59
+ parent: root,
60
+ children: bracketChildren.map((c) => c.name),
61
+ });
62
+ }
63
+ }
64
+
65
+ for (const child of children) {
66
+ await walk(join(root, child), conflicts);
67
+ }
68
+ }
69
+
70
+ async function detectRoots(args) {
71
+ if (args.length > 0) return args.map((p) => resolve(p));
72
+ const candidates = ['app', 'src/app'].map((p) => resolve(process.cwd(), p));
73
+ return candidates.filter((p) => existsSync(p));
74
+ }
75
+
76
+ async function main() {
77
+ const args = process.argv.slice(2);
78
+ const roots = await detectRoots(args);
79
+
80
+ if (roots.length === 0) {
81
+ console.log('[route-slugs] no app/ or src/app/ directory found — skipped.');
82
+ process.exit(2);
83
+ }
84
+
85
+ const conflicts = [];
86
+ for (const root of roots) {
87
+ const s = await stat(root).catch(() => null);
88
+ if (!s?.isDirectory()) continue;
89
+ await walk(root, conflicts);
90
+ }
91
+
92
+ if (conflicts.length > 0) {
93
+ console.error('');
94
+ console.error(' ROUTE SLUG CONFLICT (next.js will crash at runtime)');
95
+ console.error(' ' + '─'.repeat(60));
96
+ console.error('');
97
+ console.error(
98
+ ' Next.js requires that every bracket-named sibling under the SAME'
99
+ );
100
+ console.error(
101
+ ' parent directory uses the SAME inner slug name. Mixing names'
102
+ );
103
+ console.error(
104
+ ' produces: "You cannot use different slug names for the same'
105
+ );
106
+ console.error(' dynamic path".');
107
+ console.error('');
108
+ for (const c of conflicts) {
109
+ const rel = relative(process.cwd(), c.parent) || '.';
110
+ console.error(` in: ${rel}/`);
111
+ for (const child of c.children) {
112
+ console.error(` ├── ${child}`);
113
+ }
114
+ console.error('');
115
+ }
116
+ console.error(' Fix: pick ONE slug name per resource (e.g. [userId]) and');
117
+ console.error(' rename every conflicting sibling to match.');
118
+ console.error('');
119
+ process.exit(1);
120
+ }
121
+
122
+ const scanned = roots.map((r) => relative(process.cwd(), r) || '.').join(', ');
123
+ console.log(`[route-slugs] OK — no slug conflicts in: ${scanned}`);
124
+ process.exit(0);
125
+ }
126
+
127
+ main().catch((err) => {
128
+ console.error('[route-slugs] script failed:', err);
129
+ process.exit(1);
130
+ });
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: nextjs-app-router
3
- version: 1.0.0
3
+ version: 1.1.0
4
4
  ---
5
5
 
6
6
  # Next.js App Router — Modern Patterns
@@ -27,6 +27,77 @@ app/
27
27
  └── route.ts # API route handler
28
28
  ```
29
29
 
30
+ ---
31
+
32
+ ## Dynamic Route Slug Consistency (CRITICAL — silent build killer)
33
+
34
+ > **Next.js validates dynamic-segment slug names at REQUEST TIME, not at build time.** `next build` / `bun run build` **does not catch** this class of bug. It blows up the first time anyone hits the route in production with:
35
+ >
36
+ > ```
37
+ > Error: You cannot use different slug names for the same dynamic path ('id' !== 'userId').
38
+ > ```
39
+
40
+ ### The Rule
41
+
42
+ Inside the **same parent directory**, every `[bracket]` child segment MUST use the **same** inner name. Pick one slug name per resource and reuse it through every nested route under it.
43
+
44
+ ```
45
+ # BROKEN — same parent (app/users/), different slug names
46
+ app/users/[id]/page.tsx
47
+ app/users/[userId]/posts/page.tsx ← runtime crash
48
+
49
+ # CORRECT — one canonical slug per resource
50
+ app/users/[userId]/page.tsx
51
+ app/users/[userId]/posts/page.tsx
52
+ app/users/[userId]/posts/[postId]/page.tsx
53
+ ```
54
+
55
+ This also applies to catch-all (`[...slug]`) and optional catch-all (`[[...slug]]`) — you may not mix a `[id]` and `[...rest]` as siblings of the same parent.
56
+
57
+ ### Pre-Flight Check (run BEFORE creating any new dynamic route)
58
+
59
+ ```bash
60
+ # Quick visual scan — list every dynamic segment with its depth
61
+ find src/app app -type d -name '[[]*[]]' 2>/dev/null \
62
+ | awk -F/ '{print NF":"$0}' | sort
63
+ ```
64
+
65
+ Eyeball the output: any two paths that share a prefix up to depth `N-1` and diverge into different `[name]` at depth `N` are the bug.
66
+
67
+ ### Programmatic Check (CI gate — mandatory)
68
+
69
+ The stack ships a Bun script at `scripts/check-route-slugs.mjs`. Add it to `package.json` and wire it into the quality gate **before** `build`:
70
+
71
+ ```jsonc
72
+ {
73
+ "scripts": {
74
+ "routes:check": "bun scripts/check-route-slugs.mjs",
75
+ "prebuild": "bun run routes:check",
76
+ "build": "next build"
77
+ }
78
+ }
79
+ ```
80
+
81
+ Why `prebuild`: makes the check unskippable for anyone running `bun run build` locally, in Vercel, or in CI. The check completes in milliseconds and exits non-zero on the first conflict, with the offending parent dir and conflicting slugs in the error message.
82
+
83
+ ### When to Run
84
+
85
+ - ☑ **Before creating** any new `[something]/page.tsx` or `[something]/route.ts`
86
+ - ☑ **Before any commit** touching `app/**/[*]/**`
87
+ - ☑ **In CI** before `bun run build`
88
+ - ☑ **As `prebuild`** in `package.json` so local builds also catch it
89
+
90
+ ### Convention — name your slugs by resource, not by position
91
+
92
+ | Resource | Slug |
93
+ |---|---|
94
+ | User | `[userId]` |
95
+ | Organization / tenant | `[orgId]` or `[tenantId]` |
96
+ | Post / article | `[postId]` |
97
+ | Instance ID (Evolution API, webhook key) | `[instanceKey]` (or whatever you commit to — pick once) |
98
+
99
+ Generic `[id]` is acceptable only at the root of a resource (`app/users/[id]/...`) **if and only if** you stay with `[id]` through every nested segment under it. Mixing `[id]` and `[userId]` under the same parent is the bug.
100
+
30
101
  ## Server vs Client Components
31
102
 
32
103
  ```tsx
@@ -106,6 +177,152 @@ export async function POST(request: NextRequest) {
106
177
  }
107
178
  ```
108
179
 
180
+ ## Webhook Handler — Critical Path (avoid retry storms)
181
+
182
+ > A webhook receiver is a **critical path you do not control**. The provider (Stripe, GitHub, Evolution, Meta, etc.) will retry — often aggressively, often forever — every non-`2xx`. Any error you let propagate becomes their problem AND yours.
183
+
184
+ ### The Three Rules
185
+
186
+ 1. **Verify signature with the RAW body BEFORE parsing JSON.** Parsing first leaks payload validity into your error path and can let unsigned traffic through.
187
+ 2. **Acknowledge fast (return 2xx within ≤ 5 s).** Persist the event, hand it off to a queue / `waitUntil` / background task, then return. The HTTP handler does NOT do business logic.
188
+ 3. **Idempotency by provider event ID.** Same event arriving twice (retries, replays) MUST be a no-op. Store the event ID with a unique index.
189
+
190
+ ### Reference Receiver (Next.js Route Handler)
191
+
192
+ ```ts
193
+ // app/api/webhooks/[provider]/route.ts
194
+ import { NextRequest, NextResponse } from 'next/server';
195
+ import crypto from 'node:crypto';
196
+
197
+ export const runtime = 'nodejs'; // crypto.timingSafeEqual + raw body
198
+ export const dynamic = 'force-dynamic';
199
+
200
+ const SECRET = process.env['WEBHOOK_SECRET']!;
201
+
202
+ function verify(rawBody: string, signature: string | null): boolean {
203
+ if (!signature) return false;
204
+ const expected = crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex');
205
+ const a = Buffer.from(signature);
206
+ const b = Buffer.from(expected);
207
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
208
+ }
209
+
210
+ export async function POST(req: NextRequest) {
211
+ // 1) RAW body — never req.json() before signature check
212
+ const rawBody = await req.text();
213
+ const signature = req.headers.get('x-signature');
214
+
215
+ if (!verify(rawBody, signature)) {
216
+ // Signature failure is the ONLY 4xx we return. Provider will not retry 401.
217
+ return new NextResponse('invalid signature', { status: 401 });
218
+ }
219
+
220
+ // 2) Parse AFTER signature passes
221
+ let event: { id: string; type: string; data: unknown };
222
+ try {
223
+ event = JSON.parse(rawBody);
224
+ } catch {
225
+ // Malformed body from an authenticated source = log + 200.
226
+ // Returning 400/500 here triggers infinite retries for a payload we cannot process anyway.
227
+ logger.warn({ rawBody }, 'webhook.parse_failed');
228
+ return new NextResponse('accepted', { status: 200 });
229
+ }
230
+
231
+ // 3) Idempotency — store-or-skip on event.id (unique index)
232
+ try {
233
+ await db.webhookEvent.create({
234
+ data: { id: event.id, type: event.type, payload: event, status: 'pending' },
235
+ });
236
+ } catch (e) {
237
+ if (isUniqueViolation(e)) {
238
+ // Duplicate delivery — already accepted. Ack and move on.
239
+ return new NextResponse('duplicate', { status: 200 });
240
+ }
241
+ // DB down: signal the provider to retry (this IS our fault).
242
+ logger.error({ err: e, eventId: event.id }, 'webhook.persist_failed');
243
+ return new NextResponse('storage error', { status: 503 });
244
+ }
245
+
246
+ // 4) Hand off async. NEVER await business logic here.
247
+ // Options, in order of preference:
248
+ // (a) push to a queue (BullMQ, Inngest, QStash, SQS)
249
+ // (b) Vercel: `waitUntil(processEvent(event))` — runs after response
250
+ // (c) trigger an internal API call with `fetch(..., { keepalive: true })`
251
+ await queue.publish('webhook.received', { eventId: event.id });
252
+
253
+ // 5) Always 2xx if we got this far. Provider stops retrying.
254
+ return NextResponse.json({ received: true });
255
+ }
256
+ ```
257
+
258
+ ### The Async Processor (separate from the receiver)
259
+
260
+ The processor is where downstream calls happen. **It must absorb its own failures** — never let them bubble back into the HTTP receiver:
261
+
262
+ ```ts
263
+ // jobs/process-webhook.ts
264
+ import CircuitBreaker from 'opossum';
265
+
266
+ const downstream = new CircuitBreaker(callDownstreamAPI, {
267
+ timeout: 5_000,
268
+ errorThresholdPercentage: 50,
269
+ resetTimeout: 30_000,
270
+ });
271
+
272
+ export async function processWebhook(eventId: string) {
273
+ const event = await db.webhookEvent.findUniqueOrThrow({ where: { id: eventId } });
274
+ if (event.status === 'processed') return; // re-entrancy safety
275
+
276
+ try {
277
+ await downstream.fire(event.payload);
278
+ await db.webhookEvent.update({
279
+ where: { id: eventId },
280
+ data: { status: 'processed', processedAt: new Date() },
281
+ });
282
+ } catch (err) {
283
+ // Mark for retry from OUR side (queue redelivery + backoff),
284
+ // NOT from the provider's side. Provider already got 2xx.
285
+ await db.webhookEvent.update({
286
+ where: { id: eventId },
287
+ data: {
288
+ status: 'failed',
289
+ attempts: { increment: 1 },
290
+ lastError: serializeError(err),
291
+ },
292
+ });
293
+ logger.error({ err, eventId }, 'webhook.process_failed');
294
+ throw err; // queue will backoff + retry per OUR policy
295
+ }
296
+ }
297
+ ```
298
+
299
+ See `error-handling` Pattern 5 for circuit breaker tuning and Pattern 4 for retry+backoff.
300
+
301
+ ### FORBIDDEN — Webhook Handlers
302
+
303
+ | Anti-pattern | Why it's lethal |
304
+ |---|---|
305
+ | `await req.json()` before signature verification | Signature is computed on the raw body bytes; framework re-serialization breaks it. Also accepts unsigned traffic into your parser. |
306
+ | Doing the business logic inline in the handler | Provider timeout (≤ 5–30 s) → they retry while you're still processing → duplicate writes. |
307
+ | Returning `5xx` on downstream failures | Provider retries forever, queue floods, your downstream gets even more load. Ack 2xx, retry from your side. |
308
+ | Returning `4xx` on parse / business errors | Same retry storm. Only `4xx` justified is `401` for bad signature. |
309
+ | No idempotency key | First retry creates a duplicate user / duplicate charge / duplicate message. |
310
+ | Logging the full payload | PII / secrets in logs. Log the event ID + type; redact `data.*`. |
311
+ | One `/api/webhooks` for all providers | Each provider has its own signature scheme, secrets, retry policy. Isolate per-route (`/api/webhooks/[provider]`). |
312
+ | Trusting `X-Forwarded-For` for provider IP allowlist | Use signature verification, not IP allowlisting. Provider IPs rotate. |
313
+
314
+ ### Pre-Commit Checklist (Webhook Routes)
315
+
316
+ - [ ] Signature verified on the **raw body** before any parsing
317
+ - [ ] `timingSafeEqual` used for signature comparison (no `===`)
318
+ - [ ] Provider event ID stored with a unique index → idempotency
319
+ - [ ] Handler returns 2xx within ~1 s on the success path
320
+ - [ ] Business logic delegated to queue / `waitUntil` / background task
321
+ - [ ] Downstream calls wrapped in circuit breaker + retry+backoff
322
+ - [ ] Logs include `eventId` + `provider` + `type`; payload `data` redacted
323
+ - [ ] Tested: duplicate delivery → 200 (no duplicate side-effect)
324
+ - [ ] Tested: invalid signature → 401, never reaches the parser
325
+
109
326
  ## Metadata
110
327
 
111
328
  ```tsx
@@ -227,3 +444,7 @@ export async function createCheckout(priceId: string) {
227
444
  6. **`NEXT_PUBLIC_` with API keys, secrets, or tokens** — secrets leak to browser bundle
228
445
  7. **Calling external APIs from client components** — use Route Handlers as proxy
229
446
  8. **`process.env['SECRET']` in `'use client'` files** — only `NEXT_PUBLIC_*` vars work client-side
447
+ 9. **Mixing `[id]` / `[userId]` / `[someId]` as siblings of the same parent dir** — runtime crash; `next build` does NOT catch it. Run `bun run routes:check` (see "Dynamic Route Slug Consistency")
448
+ 10. **Webhook business logic inline in the Route Handler** — ack 2xx fast, process async (see "Webhook Handler — Critical Path")
449
+ 11. **Skipping signature verification or parsing JSON before verifying** — always verify the raw body first
450
+ 12. **Returning 5xx from a webhook on a downstream failure** — triggers provider retry storms; ack 2xx and retry from your side