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.
- package/dist/setup.js +11 -0
- package/package.json +1 -1
- package/stacks/_shared/skills/quality-gate/SKILL.md +11 -4
- package/stacks/frontend/react/skills/preline-ui/SKILL.md +6 -3
- package/stacks/frontend/react/skills/react-patterns/SKILL.md +125 -26
- package/stacks/frontend/react/skills/react-standards/SKILL.md +17 -4
- package/stacks/frontend/react/skills/react-ui-patterns/SKILL.md +106 -31
- package/stacks/frontend/react/skills/shadcn-ui/SKILL.md +284 -56
- package/stacks/frontend/react/skills/tailwind-patterns/SKILL.md +75 -16
- package/stacks/frontend/react/skills/zod-validation/SKILL.md +157 -35
- package/stacks/frontend/react-api/skills/axios-laravel-api/SKILL.md +2 -6
- package/stacks/frontend/react-api/skills/react-api-standards/SKILL.md +10 -12
- package/stacks/nodejs/scripts/check-route-slugs.mjs +130 -0
- package/stacks/nodejs/skills/nextjs-app-router/SKILL.md +222 -1
- package/stacks/nodejs/stack.json +2 -1
- package/stacks/nodejs/workflows/ci.yml +11 -0
|
@@ -1,42 +1,97 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: zod-validation
|
|
3
|
-
version:
|
|
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
|
|
7
|
+
# Zod 4 — Runtime Type Safety (2026)
|
|
7
8
|
|
|
8
|
-
**ALWAYS use Zod for form validation, API responses,
|
|
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
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
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
|
|
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:
|
|
27
|
-
email:
|
|
28
|
-
age:
|
|
29
|
-
role:
|
|
30
|
-
bio:
|
|
31
|
-
tags:
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
//
|
|
157
|
-
|
|
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
|
|
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:
|
|
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:
|
|
170
|
-
}).refine(
|
|
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
|
|
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
|
|
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:
|
|
4
|
-
description: Axios HTTP client for Laravel 12 + Sanctum SPA
|
|
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:
|
|
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
|
|
18
|
-
- **Vite
|
|
19
|
-
- **TailwindCSS
|
|
20
|
-
- **Axios
|
|
21
|
-
- **TanStack Query
|
|
22
|
-
- **
|
|
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.
|
|
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
|