diff --git a/.agent/rules/guidelines.md b/.agent/rules/guidelines.md index 16e0d4b9..211dc5f7 100644 --- a/.agent/rules/guidelines.md +++ b/.agent/rules/guidelines.md @@ -163,6 +163,7 @@ In development, `client/src/shared/lib/billing/local-plan-override.ts` lets deve These existing deviations are accepted — do not auto-fix unless explicitly tasked: - `features/settings/ui/theme-switch-button.tsx` imports from `@/app/providers/theme-provider` — intentional FSD upward import (provider coupling) +- `features/subscription/add-subscription` imports `BrandfetchPicker` from `features/brandfetch` — cross-slice import, accepted (picker is feature-level and used by only one consumer) - `features/category/ai-generator` cross-imports `features/brandfetch` and uses a deep path into `features/category/manage-categories/ui/emoji-picker` - `server/src/domains/subscription/subscriptionService.ts` imports `{ db }` directly — some orchestration queries bypass the repository layer @@ -208,7 +209,7 @@ These existing deviations are accepted — do not auto-fix unless explicitly tas | ----------------------------------------------------------------- | ------------------------------------------ | | `client/src/app/routes/routeTree.gen.ts` | TanStack Router Vite plugin | | `client/src/shared/lib/i18n/**` | Paraglide (`bun --cwd client run prepare`) | -| `CLAUDE.md`, `.agent/rules/guidelines.md`, `.junie/guidelines.md` | `bun run guidelines:sync` | +| `CLAUDE.md`, `.agent/rules/guidelines.md` | `bun run guidelines:sync` | | `**/dist/**`, `**/.turbo/**` | Build tools | Edit source inputs and rerun the appropriate tool instead of hand-editing these. @@ -237,7 +238,6 @@ Run the narrowest relevant checks first, then escalate for cross-workspace impac - Canonical source: `AGENTS.md` (this file). - Generated mirrors: - `.agent/rules/guidelines.md` - - `.junie/guidelines.md` - `CLAUDE.md` - Do not hand-edit generated mirrors. - Use: diff --git a/.agents/skills/add-i18n-key/SKILL.md b/.agents/skills/add-i18n-key/SKILL.md new file mode 100644 index 00000000..a49132aa --- /dev/null +++ b/.agents/skills/add-i18n-key/SKILL.md @@ -0,0 +1,24 @@ +--- +name: add-i18n-key +description: Add a new Paraglide i18n message key to both uk and en locale files, then recompile +disable-model-invocation: true +--- + +# Add i18n Key + +Usage: `/add-i18n-key "" ""` + +## Steps + +1. Add the key to `client/messages/uk.json` (Ukrainian — base locale, alphabetical order) +2. Add the same key to `client/messages/en.json` (English) +3. Run `bun run --cwd client prepare` to recompile Paraglide output +4. Confirm the compiled key appears in `client/src/shared/lib/i18n/messages.js` + +## Rules + +- Keys use camelCase +- Ukrainian is the source locale — always provide it even when English is the target +- Never edit anything under `client/src/shared/lib/i18n/` — it is generated +- If the key already exists, report a conflict instead of overwriting +- Maintain alphabetical key order within each JSON file diff --git a/.agents/skills/clerk-custom-ui/.claude-plugin/plugin.json b/.agents/skills/clerk-custom-ui/.claude-plugin/plugin.json deleted file mode 100644 index 2e464394..00000000 --- a/.agents/skills/clerk-custom-ui/.claude-plugin/plugin.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "clerk-custom-ui", - "description": "Build custom sign-in/sign-up flows and customize component appearance. Use for custom auth flows, appearance styling, non-standard patterns.", - "version": "1.0.0", - "author": { - "name": "Clerk", - "email": "support@clerk.com" - }, - "license": "MIT", - "homepage": "https://clerk.com/docs", - "repository": "https://github.com/clerk/skills", - "keywords": ["auth", "custom-ui", "appearance", "sign-in", "sign-up"], - "category": "authentication" -} diff --git a/.agents/skills/clerk-custom-ui/SKILL.md b/.agents/skills/clerk-custom-ui/SKILL.md index af9e71d3..e6e05dc9 100644 --- a/.agents/skills/clerk-custom-ui/SKILL.md +++ b/.agents/skills/clerk-custom-ui/SKILL.md @@ -1,29 +1,61 @@ --- name: clerk-custom-ui -description: Customize Clerk component appearance - themes, layout, colors, fonts, CSS. Use for appearance styling, visual customization, branding. +description: Custom authentication flows and component appearance - hooks (useSignIn, + useSignUp), themes, colors, fonts, CSS. Use for custom sign-in/sign-up flows, appearance + styling, visual customization, branding. allowed-tools: WebFetch license: MIT metadata: author: clerk - version: "1.0.0" + version: 2.3.0 --- -# Component Customization +# Custom UI -> **Prerequisite**: Ensure `ClerkProvider` wraps your app. See `setup/`. +> **Prerequisite**: Ensure `ClerkProvider` wraps your app. See `clerk-setup` skill. +> +> **Version**: Check `package.json` for the SDK version — see `clerk` skill for the version table. This determines which custom flow references to use below. -## Component Customization Options +This skill covers two areas: +1. **Custom authentication flows** — build your own sign-in/sign-up UI with hooks +2. **Appearance customization** — theme, style, and brand Clerk's pre-built components -| Task | Documentation | -| ---------------------------------- | ----------------------------------------------------------------------------------------- | -| Appearance prop overview | https://clerk.com/docs/nextjs/guides/customizing-clerk/appearance-prop/overview | -| Layout (structure, logo, buttons) | https://clerk.com/docs/nextjs/guides/customizing-clerk/appearance-prop/layout | -| Themes (pre-built dark/light) | https://clerk.com/docs/nextjs/guides/customizing-clerk/appearance-prop/themes | -| Variables (colors, fonts, spacing) | https://clerk.com/docs/nextjs/guides/customizing-clerk/appearance-prop/variables | -| CAPTCHA configuration | https://clerk.com/docs/nextjs/guides/customizing-clerk/appearance-prop/captcha | -| Bring your own CSS | https://clerk.com/docs/nextjs/guides/customizing-clerk/appearance-prop/bring-your-own-css | +## What Do You Need? -## Appearance Pattern +| Task | Reference | +|------|-----------| +| Custom sign-in (Core 2 / LTS) | core-2/custom-sign-in.md | +| Custom sign-up (Core 2 / LTS) | core-2/custom-sign-up.md | +| Custom sign-in (Current SDK v7+) | core-3/custom-sign-in.md | +| Custom sign-up (Current SDK v7+) | core-3/custom-sign-up.md | +| Show component pattern (Current SDK) | core-3/show-component.md | + +## Custom Flow References + +| Task | Core 2 | Current | +|------|--------|---------| +| Custom sign-in (useSignIn) | `core-2/custom-sign-in.md` | `core-3/custom-sign-in.md` | +| Custom sign-up (useSignUp) | `core-2/custom-sign-up.md` | `core-3/custom-sign-up.md` | +| `` component | *(use ``, ``, ``)* | `core-3/show-component.md` | + +--- + +## Appearance Customization + +Appearance customization applies to both Core 2 and the current SDK. + +### Component Customization Options + +| Task | Documentation | +|------|---------------| +| Appearance prop overview | https://clerk.com/docs/nextjs/guides/customizing-clerk/appearance-prop/overview | +| Options (structure, logo, buttons) | https://clerk.com/docs/nextjs/guides/customizing-clerk/appearance-prop/layout | +| Themes (pre-built dark/light) | https://clerk.com/docs/nextjs/guides/customizing-clerk/appearance-prop/themes | +| Variables (colors, fonts, spacing) | https://clerk.com/docs/nextjs/guides/customizing-clerk/appearance-prop/variables | +| CAPTCHA configuration | https://clerk.com/docs/nextjs/guides/customizing-clerk/appearance-prop/captcha | +| Bring your own CSS | https://clerk.com/docs/nextjs/guides/customizing-clerk/appearance-prop/bring-your-own-css | + +### Appearance Pattern ```typescript ``` +> **Core 2 ONLY (skip if current SDK):** The `options` property was named `layout`. Use `layout: { logoImageUrl: '...', socialButtonsVariant: '...' }` instead of `options`. + ### variables (colors, typography, borders) -| Property | Description | -| ----------------- | ----------------------------------- | -| `colorPrimary` | Primary color throughout | -| `colorBackground` | Background color | -| `borderRadius` | Border radius (default: `0.375rem`) | +| Property | Description | +|----------|-------------| +| `colorPrimary` | Primary color throughout | +| `colorBackground` | Background color | +| `borderRadius` | Border radius (default: `0.375rem`) | + +**Opacity change:** `colorRing` and `colorModalBackdrop` now render at full opacity. Use explicit `rgba()` values if you need transparency. + +> **Core 2 ONLY (skip if current SDK):** `colorRing` and `colorModalBackdrop` rendered at 15% opacity by default. -### layout (structure, logo, social buttons) +### options (structure, logo, social buttons) -| Property | Description | -| ------------------------ | --------------------------------------------- | -| `logoImageUrl` | URL to custom logo | -| `socialButtonsVariant` | `'blockButton'` \| `'iconButton'` \| `'auto'` | -| `socialButtonsPlacement` | `'top'` \| `'bottom'` | +| Property | Description | +|----------|-------------| +| `logoImageUrl` | URL to custom logo | +| `socialButtonsVariant` | `'blockButton'` \| `'iconButton'` \| `'auto'` | +| `socialButtonsPlacement` | `'top'` \| `'bottom'` | +| `showOptionalFields` | Show optional fields (default: `false`) | -## shadcn Theme +> **Core 2 ONLY (skip if current SDK):** This property is called `layout`, not `options`. Also, `showOptionalFields` defaulted to `true`. -**If the project has `components.json`** (shadcn/ui installed), use the shadcn theme: +### Themes + +Install themes from `@clerk/ui`: + +```bash +npm install @clerk/ui +``` ```typescript -import { shadcn } from '@clerk/themes' +import { dark } from '@clerk/ui/themes' - + ``` -Also import shadcn CSS in your global.css: +> **Core 2 ONLY (skip if current SDK):** Themes are imported from `@clerk/themes` (install `@clerk/themes` instead of `@clerk/ui`). Example: `import { dark } from '@clerk/themes'`. +**Theme stacking** — pass an array where the last theme takes precedence: + +```typescript +import { dark, neobrutalism } from '@clerk/ui/themes' + + +``` + +**CSS `color-scheme` support** — the default theme respects the CSS `color-scheme` property for automatic light/dark mode toggling. + +**Available themes:** `dark`, `neobrutalism`, `shadcn`, `simple` + +#### shadcn Theme + +> **IMPORTANT:** If the project has `components.json` (shadcn/ui installed), ALWAYS apply the shadcn theme as the FIRST customization step. This ensures Clerk components visually match the existing shadcn design system. Skip this only if the user explicitly requests a different theme. + +**If the project uses shadcn/ui** (check for `components.json` in the project root), **always use the shadcn theme**: + +```typescript +import { shadcn } from '@clerk/ui/themes' + + +``` + +Also import shadcn CSS in your global styles: ```css -@import "tailwindcss"; -@import "@clerk/themes/shadcn.css"; +@import 'tailwindcss'; +@import '@clerk/ui/themes/shadcn.css'; ``` +> **Core 2 ONLY (skip if current SDK):** Import from `@clerk/themes` and `@clerk/themes/shadcn.css`: +> ```typescript +> import { shadcn } from '@clerk/themes' +> ``` +> ```css +> @import '@clerk/themes/shadcn.css'; +> ``` + ## Workflow -1. Identify customization needs (colors, layout, theme, CSS) -2. WebFetch the appropriate documentation from table above -3. Follow official code examples from the docs -4. Apply appearance prop to your Clerk components +1. Identify customization needs (custom flow or appearance) +2. For custom flows: check SDK version → read appropriate `core-2/` or `core-3/` reference +3. For appearance: WebFetch the appropriate documentation from table above +4. Apply appearance prop to your Clerk components or build custom flow with hooks ## Common Pitfalls -| Issue | Solution | -| -------------------- | -------------------------------------------------------------------- | -| Colors not applying | Use `colorPrimary` not `primaryColor` | -| Logo not showing | Put `logoImageUrl` inside `layout: {}` | -| Social buttons wrong | Add `socialButtonsVariant: 'iconButton'` in `layout` | -| Styling not working | Use appearance prop, not direct CSS (unless with bring-your-own-css) | +| Issue | Solution | +|-------|----------| +| Colors not applying | Use `colorPrimary` not `primaryColor` | +| Logo not showing | Put `logoImageUrl` inside `options: {}` (or `layout: {}` in Core 2) | +| Social buttons wrong | Add `socialButtonsVariant: 'iconButton'` in `options` (or `layout` in Core 2) | +| Styling not working | Use appearance prop, not direct CSS (unless with bring-your-own-css) | +| Hook returns different shape | Check SDK version — Core 2 and current have completely different `useSignIn`/`useSignUp` APIs | + +## See Also + +- `clerk-setup` - Initial Clerk install +- `clerk-nextjs-patterns` - Next.js patterns +- `clerk-orgs` - B2B organizations diff --git a/.agents/skills/clerk-custom-ui/core-2/custom-sign-in.md b/.agents/skills/clerk-custom-ui/core-2/custom-sign-in.md new file mode 100644 index 00000000..28759ded --- /dev/null +++ b/.agents/skills/clerk-custom-ui/core-2/custom-sign-in.md @@ -0,0 +1,224 @@ +# Custom Sign-In Flow (Core 2) + +> This document covers the **older SDK** (`@clerk/nextjs` v5–v6, `@clerk/clerk-react` v5–v6, `@clerk/clerk-expo` v1–v2). For the current SDK, see `core-3/custom-sign-in.md`. + +Build a custom sign-in experience using the `useSignIn()` hook. + +## Hook API + +```typescript +import { useSignIn } from '@clerk/nextjs' // or @clerk/clerk-react, @clerk/clerk-expo + +const { signIn, isLoaded, setActive } = useSignIn() +``` + +| Property | Type | Description | +|----------|------|-------------| +| `signIn` | `SignIn` | Sign-in object with methods | +| `isLoaded` | `boolean` | Whether the hook has loaded | +| `setActive` | `(params) => Promise` | Sets the active session | + +## Sign-In Flow + +### 1. Create Sign-In + +```typescript +const result = await signIn.create({ + identifier: 'user@example.com', + password: 'securePassword123', +}) +``` + +### 2. First Factor Verification + +If additional verification is needed (email code, phone code): + +```typescript +// Prepare first factor +await signIn.prepareFirstFactor({ + strategy: 'email_code', // or 'phone_code' +}) + +// Attempt first factor +const result = await signIn.attemptFirstFactor({ + strategy: 'email_code', + code: '123456', +}) +``` + +### 3. Second Factor (MFA) + +If the sign-in requires MFA: + +```typescript +// Prepare second factor +await signIn.prepareSecondFactor({ + strategy: 'email_code', // or 'phone_code' +}) + +// Attempt second factor +const result = await signIn.attemptSecondFactor({ + strategy: 'totp', // or 'email_code', 'phone_code', 'backup_code' + code: '123456', +}) +``` + +### 4. Finalize + +Set the active session after successful authentication: + +```typescript +await setActive({ session: signIn.createdSessionId }) +``` + +### Password Reset + +```typescript +// 1. Start reset flow +await signIn.create({ strategy: 'reset_password_email_code', identifier: 'user@example.com' }) + +// or prepare after initial create: +await signIn.prepareFirstFactor({ strategy: 'reset_password_email_code' }) + +// 2. Verify reset code +await signIn.attemptFirstFactor({ strategy: 'reset_password_email_code', code: '123456' }) + +// 3. Set new password +await signIn.resetPassword({ password: 'newSecurePassword123' }) +``` + +### SSO (OAuth) + +```typescript +await signIn.authenticateWithRedirect({ + strategy: 'oauth_google', // or 'oauth_github', etc. + redirectUrl: '/sso-callback', + redirectUrlComplete: '/', +}) +``` + +## Error Handling + +Use try/catch with `isClerkAPIResponseError()`: + +```typescript +import { isClerkAPIResponseError } from '@clerk/nextjs/errors' + +try { + await signIn.create({ identifier, password }) +} catch (err) { + if (isClerkAPIResponseError(err)) { + err.errors.forEach((e) => { + console.log(e.code) // e.g. 'form_identifier_not_found' + console.log(e.message) // Human-readable message + console.log(e.longMessage) // Detailed message + }) + } +} +``` + +## Complete Example: Email/Password with MFA + +```tsx +'use client' +import { useState } from 'react' +import { useSignIn } from '@clerk/nextjs' +import { isClerkAPIResponseError } from '@clerk/nextjs/errors' +import { useRouter } from 'next/navigation' + +export default function SignInPage() { + const { signIn, isLoaded, setActive } = useSignIn() + const router = useRouter() + + const [identifier, setIdentifier] = useState('') + const [password, setPassword] = useState('') + const [mfaCode, setMfaCode] = useState('') + const [step, setStep] = useState<'credentials' | 'mfa'>('credentials') + const [error, setError] = useState('') + + if (!isLoaded) return
Loading...
+ + async function handleSignIn(e: React.FormEvent) { + e.preventDefault() + setError('') + + try { + const result = await signIn.create({ identifier, password }) + + if (result.status === 'needs_second_factor') { + setStep('mfa') + return + } + + if (result.status === 'complete') { + await setActive({ session: result.createdSessionId }) + router.push('/') + } + } catch (err) { + if (isClerkAPIResponseError(err)) { + setError(err.errors[0]?.message || 'Sign in failed') + } + } + } + + async function handleMFA(e: React.FormEvent) { + e.preventDefault() + setError('') + + try { + const result = await signIn.attemptSecondFactor({ + strategy: 'totp', + code: mfaCode, + }) + + if (result.status === 'complete') { + await setActive({ session: result.createdSessionId }) + router.push('/') + } + } catch (err) { + if (isClerkAPIResponseError(err)) { + setError(err.errors[0]?.message || 'Verification failed') + } + } + } + + if (step === 'mfa') { + return ( +
+ setMfaCode(e.target.value)} + placeholder="Enter MFA code" + /> + {error &&

{error}

} + +
+ ) + } + + return ( +
+ setIdentifier(e.target.value)} + placeholder="Email" + /> + setPassword(e.target.value)} + placeholder="Password" + /> + {error &&

{error}

} + +
+ ) +} +``` + +## Docs + +- [Custom sign-in flow](https://clerk.com/docs/custom-flows/overview) +- [useSignIn() reference](https://clerk.com/docs/references/react/use-sign-in) diff --git a/.agents/skills/clerk-custom-ui/core-2/custom-sign-up.md b/.agents/skills/clerk-custom-ui/core-2/custom-sign-up.md new file mode 100644 index 00000000..574dbe73 --- /dev/null +++ b/.agents/skills/clerk-custom-ui/core-2/custom-sign-up.md @@ -0,0 +1,190 @@ +# Custom Sign-Up Flow (Core 2) + +> This document covers the **older SDK** (`@clerk/nextjs` v5–v6, `@clerk/clerk-react` v5–v6, `@clerk/clerk-expo` v1–v2). For the current SDK, see `core-3/custom-sign-up.md`. + +Build a custom sign-up experience using the `useSignUp()` hook. + +## Hook API + +```typescript +import { useSignUp } from '@clerk/nextjs' // or @clerk/clerk-react, @clerk/clerk-expo + +const { signUp, isLoaded, setActive } = useSignUp() +``` + +| Property | Type | Description | +|----------|------|-------------| +| `signUp` | `SignUp` | Sign-up object with methods | +| `isLoaded` | `boolean` | Whether the hook has loaded | +| `setActive` | `(params) => Promise` | Sets the active session | + +## Sign-Up Flow + +### 1. Create Sign-Up + +```typescript +const result = await signUp.create({ + emailAddress: 'user@example.com', + password: 'securePassword123', + firstName: 'Jane', // optional + lastName: 'Doe', // optional +}) +``` + +### 2. Prepare Verification + +Send a verification code to the user's email or phone: + +```typescript +await signUp.prepareVerification({ + strategy: 'email_code', // or 'phone_code', 'email_link' +}) +``` + +### 3. Attempt Verification + +Verify the code the user received: + +```typescript +const result = await signUp.attemptVerification({ + strategy: 'email_code', + code: '123456', +}) +``` + +### 4. Finalize + +Set the active session after successful sign-up: + +```typescript +await setActive({ session: signUp.createdSessionId }) +``` + +### SSO (OAuth) + +```typescript +await signUp.authenticateWithRedirect({ + strategy: 'oauth_google', + redirectUrl: '/sso-callback', + redirectUrlComplete: '/', +}) +``` + +## Error Handling + +Use try/catch with `isClerkAPIResponseError()`: + +```typescript +import { isClerkAPIResponseError } from '@clerk/nextjs/errors' + +try { + await signUp.create({ emailAddress, password }) +} catch (err) { + if (isClerkAPIResponseError(err)) { + err.errors.forEach((e) => { + console.log(e.code) // e.g. 'form_password_pwned' + console.log(e.message) // Human-readable message + console.log(e.longMessage) // Detailed message + }) + } +} +``` + +## Complete Example: Email/Password with Email Verification + +```tsx +'use client' +import { useState } from 'react' +import { useSignUp } from '@clerk/nextjs' +import { isClerkAPIResponseError } from '@clerk/nextjs/errors' +import { useRouter } from 'next/navigation' + +export default function SignUpPage() { + const { signUp, isLoaded, setActive } = useSignUp() + const router = useRouter() + + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [code, setCode] = useState('') + const [step, setStep] = useState<'register' | 'verify'>('register') + const [error, setError] = useState('') + + if (!isLoaded) return
Loading...
+ + async function handleRegister(e: React.FormEvent) { + e.preventDefault() + setError('') + + try { + await signUp.create({ emailAddress: email, password }) + await signUp.prepareVerification({ strategy: 'email_code' }) + setStep('verify') + } catch (err) { + if (isClerkAPIResponseError(err)) { + setError(err.errors[0]?.message || 'Sign up failed') + } + } + } + + async function handleVerify(e: React.FormEvent) { + e.preventDefault() + setError('') + + try { + const result = await signUp.attemptVerification({ + strategy: 'email_code', + code, + }) + + if (result.status === 'complete') { + await setActive({ session: result.createdSessionId }) + router.push('/') + } + } catch (err) { + if (isClerkAPIResponseError(err)) { + setError(err.errors[0]?.message || 'Verification failed') + } + } + } + + if (step === 'verify') { + return ( +
+

Check your email for a verification code.

+ setCode(e.target.value)} + placeholder="Verification code" + /> + {error &&

{error}

} + +
+ ) + } + + return ( +
+ setEmail(e.target.value)} + placeholder="Email" + /> + setPassword(e.target.value)} + placeholder="Password" + /> + {error &&

{error}

} + +
+ ) +} +``` + +## Docs + +- [Custom sign-up flow](https://clerk.com/docs/custom-flows/overview) +- [useSignUp() reference](https://clerk.com/docs/references/react/use-sign-up) diff --git a/.agents/skills/clerk-custom-ui/core-3/custom-sign-in.md b/.agents/skills/clerk-custom-ui/core-3/custom-sign-in.md new file mode 100644 index 00000000..50f13c27 --- /dev/null +++ b/.agents/skills/clerk-custom-ui/core-3/custom-sign-in.md @@ -0,0 +1,314 @@ +# Custom Sign-In Flow + +Build a custom sign-in experience using the `useSignIn()` hook. + +## Hook API + +```typescript +import { useSignIn } from '@clerk/nextjs' // or @clerk/react, @clerk/expo + +const { signIn, errors, fetchStatus } = useSignIn() +``` + +| Property | Type | Description | +|----------|------|-------------| +| `signIn` | `SignInFuture` | Sign-in object with namespaced methods | +| `errors` | `Errors` | Structured error object | +| `fetchStatus` | `'idle' \| 'fetching'` | Network request status | + +## Sign-In Methods + +### Password + +```typescript +const { error } = await signIn.password({ + identifier: 'user@example.com', + password: 'securePassword123', +}) +``` + +### SSO (OAuth / Enterprise) + +```typescript +const { error } = await signIn.sso({ + strategy: 'oauth_google', // or 'oauth_github', 'enterprise_sso', etc. + redirectUrl: '/dashboard', // where to go after SSO completes + redirectCallbackUrl: '/sso-callback', // intermediate callback route +}) +``` + +### Passkey + +```typescript +const { error } = await signIn.passkey({ flow: 'discoverable' }) +``` + +### Web3 + +```typescript +const { error } = await signIn.web3({ strategy: 'web3_solana_signature' }) +// or +const { error } = await signIn.web3({ strategy: 'web3_base_signature' }) +``` + +### Ticket (Invitation link) + +```typescript +const { error } = await signIn.ticket({ ticket: 'ticket_abc123' }) +``` + +### Email Code + +```typescript +// Send code (emailAddress is optional if a signIn already exists from a prior method call) +const { error } = await signIn.emailCode.sendCode({ emailAddress: 'user@example.com' }) + +// Verify code +const { error } = await signIn.emailCode.verifyCode({ code: '123456' }) +``` + +### Phone Code + +```typescript +// Send code (phoneNumber is optional if a signIn already exists from a prior method call) +const { error } = await signIn.phoneCode.sendCode({ phoneNumber: '+12015551234' }) + +// Verify code +const { error } = await signIn.phoneCode.verifyCode({ code: '123456' }) +``` + +## MFA (Second Factor) + +A second factor is required when `signIn.status` is one of: +- `'needs_second_factor'` — user has MFA enabled (TOTP, backup codes, etc.) +- `'needs_client_trust'` — new device sign-in without MFA; requires email or phone code verification + +```typescript +// TOTP (Authenticator app) +const { error } = await signIn.mfa.verifyTOTP({ code: '123456' }) + +// Backup code +const { error } = await signIn.mfa.verifyBackupCode({ code: 'backup-code-here' }) + +// Email code +const { error: sendErr } = await signIn.mfa.sendEmailCode() +const { error: verifyErr } = await signIn.mfa.verifyEmailCode({ code: '123456' }) + +// Phone code +const { error: sendErr } = await signIn.mfa.sendPhoneCode() +const { error: verifyErr } = await signIn.mfa.verifyPhoneCode({ code: '123456' }) +``` + +## Password Reset + +```typescript +// 1. Send reset code +const { error } = await signIn.resetPasswordEmailCode.sendCode() + +// 2. Verify the code +const { error } = await signIn.resetPasswordEmailCode.verifyCode({ code: '123456' }) + +// 3. Submit new password +const { error } = await signIn.resetPasswordEmailCode.submitPassword({ + password: 'newSecurePassword123', +}) +``` + +## Client Trust + +When a user signs in with a valid password from a new device without MFA enabled, the sign-in status becomes `needs_client_trust`. This requires an additional verification step: + +```typescript +if (signIn.status === 'needs_client_trust') { + // Check supportedSecondFactors for available methods (email_code or phone_code) + const factors = signIn.supportedSecondFactors + // Use the appropriate mfa method to verify +} +``` + +## Finalizing Sign-In + +After successful authentication, call `finalize()` to activate the session: + +```typescript +await signIn.finalize({ + navigate: async ({ session, decorateUrl }) => { + const destination = session.currentTask + ? `/sign-in/tasks/${session.currentTask.key}` + : '/' + const url = decorateUrl(destination) + // decorateUrl may return an absolute URL for Safari ITP + if (url.startsWith('http')) { + window.location.href = url + } else { + router.push(url) + } + }, +}) +``` + +- `decorateUrl(path)` — decorates the URL with session info (required to support Safari's Intelligent Tracking Prevention). May return an absolute URL. +- `session.currentTask` — check for pending session tasks before redirecting + +### Reset State + +Clear local sign-in state and start over: + +```typescript +signIn.reset() +``` + +## Error Handling + +All methods return `Promise<{ error: ClerkError | null }>`. Errors are also available reactively on the hook: + +```typescript +const { signIn, errors } = useSignIn() + +// Field-level errors +errors?.fields?.identifier // { code, message, longMessage? } +errors?.fields?.password // { code, message, longMessage? } +errors?.fields?.code // { code, message, longMessage? } + +// Global errors (not tied to a field) +errors?.global // ClerkGlobalHookError[] | null + +// Raw error array +errors?.raw // ClerkError[] | null +``` + +## Complete Example: Email/Password with MFA + +From [the docs](https://clerk.com/docs/guides/development/custom-flows/authentication/multi-factor-authentication). Supports SMS verification codes, authenticator app (TOTP), and backup codes. + +```tsx +'use client' + +import { useSignIn } from '@clerk/nextjs' +import { useRouter } from 'next/navigation' + +export default function Page() { + const { signIn, errors, fetchStatus } = useSignIn() + const router = useRouter() + + const handleSubmit = async (formData: FormData) => { + const emailAddress = formData.get('email') as string + const password = formData.get('password') as string + + await signIn.password({ + emailAddress, + password, + }) + + // If you're using the authenticator app strategy, remove this check. + if (signIn.status === 'needs_second_factor') { + await signIn.mfa.sendPhoneCode() + } + + if (signIn.status === 'complete') { + await signIn.finalize({ + navigate: ({ session, decorateUrl }) => { + if (session?.currentTask) { + // Handle pending session tasks + // See https://clerk.com/docs/guides/development/custom-flows/authentication/session-tasks + console.log(session?.currentTask) + return + } + + const url = decorateUrl('/') + if (url.startsWith('http')) { + window.location.href = url + } else { + router.push(url) + } + }, + }) + } + } + + const handleMFAVerification = async (formData: FormData) => { + const code = formData.get('code') as string + const useBackupCode = formData.get('useBackupCode') === 'on' + + if (useBackupCode) { + await signIn.mfa.verifyBackupCode({ code }) + } else { + await signIn.mfa.verifyPhoneCode({ code }) + // If you're using the authenticator app strategy, use the following method instead: + // await signIn.mfa.verifyTOTP({ code }) + } + + if (signIn.status === 'complete') { + await signIn.finalize({ + navigate: ({ session, decorateUrl }) => { + if (session?.currentTask) { + // Handle pending session tasks + // See https://clerk.com/docs/guides/development/custom-flows/authentication/session-tasks + console.log(session?.currentTask) + return + } + + const url = decorateUrl('/') + if (url.startsWith('http')) { + window.location.href = url + } else { + router.push(url) + } + }, + }) + } + } + + if (signIn.status === 'needs_second_factor') { + return ( +
+

Verify your account

+
+
+ + + {errors.fields.code &&

{errors.fields.code.message}

} +
+
+ +
+ +
+
+ ) + } + + return ( + <> +

Sign in

+
+
+ + + {errors.fields.identifier &&

{errors.fields.identifier.message}

} +
+
+ + + {errors.fields.password &&

{errors.fields.password.message}

} +
+ +
+ {errors &&

{JSON.stringify(errors, null, 2)}

} + + ) +} +``` + +## Docs + +- [Custom sign-in flow](https://clerk.com/docs/custom-flows/overview) +- [MFA custom flow](https://clerk.com/docs/guides/development/custom-flows/authentication/multi-factor-authentication) +- [useSignIn() reference](https://clerk.com/docs/references/react/use-sign-in) diff --git a/.agents/skills/clerk-custom-ui/core-3/custom-sign-up.md b/.agents/skills/clerk-custom-ui/core-3/custom-sign-up.md new file mode 100644 index 00000000..ac243bad --- /dev/null +++ b/.agents/skills/clerk-custom-ui/core-3/custom-sign-up.md @@ -0,0 +1,259 @@ +# Custom Sign-Up Flow + +Build a custom sign-up experience using the `useSignUp()` hook. + +## Hook API + +```typescript +import { useSignUp } from '@clerk/nextjs' // or @clerk/react, @clerk/expo + +const { signUp, errors, fetchStatus } = useSignUp() +``` + +| Property | Type | Description | +|----------|------|-------------| +| `signUp` | `SignUpFuture` | Sign-up object with namespaced methods | +| `errors` | `Errors` | Structured error object | +| `fetchStatus` | `'idle' \| 'fetching'` | Network request status | + +## Sign-Up Methods + +### Password (Email/Password) + +```typescript +const { error } = await signUp.password({ + emailAddress: 'user@example.com', + password: 'securePassword123', + firstName: 'Jane', // optional + lastName: 'Doe', // optional +}) +``` + +### SSO (OAuth) + +```typescript +const { error } = await signUp.sso({ + strategy: 'oauth_google', // or 'oauth_github', etc. + redirectUrl: '/dashboard', // where to go after SSO completes + redirectCallbackUrl: '/sso-callback', // intermediate callback route +}) +``` + +### Web3 + +```typescript +const { error } = await signUp.web3({ strategy: 'web3_solana_signature' }) +``` + +### Update (add fields to existing sign-up) + +Use `update()` to add optional fields (name, metadata, legal acceptance, locale) to an existing sign-up before finalization. + +```typescript +const { error } = await signUp.update({ + firstName: 'Jane', + lastName: 'Doe', + unsafeMetadata: { referralSource: 'twitter' }, + legalAccepted: true, +}) +``` + +## Email / Phone Verification + +After creating a sign-up, verify the user's email or phone: + +### Email Code + +```typescript +// Send verification code +const { error } = await signUp.verifications.sendEmailCode() + +// Verify the code +const { error } = await signUp.verifications.verifyEmailCode({ code: '123456' }) +``` + +### Phone Code + +```typescript +// Send verification code +const { error } = await signUp.verifications.sendPhoneCode() + +// Verify the code +const { error } = await signUp.verifications.verifyPhoneCode({ code: '123456' }) +``` + +### Email Link + +```typescript +// verificationUrl: where the user lands after clicking the email link (relative or absolute) +const { error } = await signUp.verifications.sendEmailLink({ verificationUrl: '/verify' }) +// User clicks the link in their email to verify +``` + +## Finalizing Sign-Up + +After successful sign-up and verification, call `finalize()` to activate the session: + +```typescript +await signUp.finalize({ + navigate: async ({ session, decorateUrl }) => { + const destination = session.currentTask + ? `/sign-up/tasks/${session.currentTask.key}` + : '/' + const url = decorateUrl(destination) + // decorateUrl may return an absolute URL for Safari ITP + if (url.startsWith('http')) { + window.location.href = url + } else { + router.push(url) + } + }, +}) +``` + +### Transferable Sign-Ups + +If `signUp.isTransferable` is `true`, the identifier matches an existing user and the sign-up should be transferred to a sign-in flow. This involves coordinating between sign-up and sign-in resources. See the [transferable sign-up docs](https://clerk.com/docs/custom-flows/overview) for the full implementation. + +### Reset State + +Clear local sign-up state and start over: + +```typescript +signUp.reset() +``` + +## Error Handling + +All methods return `Promise<{ error: ClerkError | null }>`. Errors are also available reactively on the hook: + +```typescript +const { signUp, errors } = useSignUp() + +// Field-level errors +errors?.fields?.emailAddress // { code, message, longMessage? } +errors?.fields?.password // { code, message, longMessage? } +errors?.fields?.firstName // { code, message, longMessage? } +errors?.fields?.lastName // { code, message, longMessage? } +errors?.fields?.phoneNumber // { code, message, longMessage? } +errors?.fields?.username // { code, message, longMessage? } +errors?.fields?.code // { code, message, longMessage? } + +// Global errors +errors?.global // ClerkGlobalHookError[] | null + +// Raw error array +errors?.raw // ClerkError[] | null +``` + +## Complete Example: Phone OTP Sign-Up + +From [the docs](https://clerk.com/docs/guides/development/custom-flows/authentication/email-sms-otp). Uses phone OTP with inline comments for adapting to email OTP. + +```tsx +'use client' + +import * as React from 'react' +import { useAuth, useSignUp } from '@clerk/nextjs' +import { useRouter } from 'next/navigation' + +export default function SignUpPage() { + const { signUp, errors, fetchStatus } = useSignUp() + const { isSignedIn } = useAuth() + const router = useRouter() + + const handleSubmit = async (formData: FormData) => { + // For email OTP: collect the email address instead of the phone number + const phoneNumber = formData.get('phoneNumber') as string + + // For email OTP: change create({ phoneNumber }) to create({ emailAddress }) + const error = await signUp.create({ phoneNumber }) + + // For email OTP: change sendPhoneCode() to sendEmailCode() + if (!error) await signUp.verifications.sendPhoneCode() + } + + const handleVerify = async (formData: FormData) => { + const code = formData.get('code') as string + + // For email OTP: change verifyPhoneCode() to verifyEmailCode() + await signUp.verifications.verifyPhoneCode({ code }) + + if (signUp.status === 'complete') { + await signUp.finalize({ + navigate: ({ session, decorateUrl }) => { + if (session?.currentTask) { + // Handle pending session tasks + // See https://clerk.com/docs/guides/development/custom-flows/authentication/session-tasks + console.log(session?.currentTask) + return + } + + const url = decorateUrl('/') + if (url.startsWith('http')) { + window.location.href = url + } else { + router.push(url) + } + }, + }) + } + } + + if (signUp.status === 'complete' || isSignedIn) { + return null + } + + if ( + signUp.status === 'missing_requirements' && + // For email OTP: check for phone_number instead of email_address + signUp.unverifiedFields.includes('phone_number') && + signUp.missingFields.length === 0 + ) { + return ( + <> +

Verify your account

+
+
+ + +
+ {errors.fields.code &&

{errors.fields.code.message}

} + +
+ {/* For email OTP: change sendPhoneCode() to sendEmailCode() */} + + + ) + } + + return ( + <> +

Sign up

+
+ {/* For email OTP: collect the emailAddress instead */} +
+ + + {errors.fields.phoneNumber &&

{errors.fields.phoneNumber.message}

} +
+ +
+ {errors &&

{JSON.stringify(errors, null, 2)}

} + + {/* Required for sign-up flows. Clerk's bot sign-up protection is enabled by default */} +
+ + ) +} +``` + +## Docs + +- [Custom sign-up flow](https://clerk.com/docs/custom-flows/overview) +- [Email/phone OTP custom flow](https://clerk.com/docs/guides/development/custom-flows/authentication/email-sms-otp) +- [useSignUp() reference](https://clerk.com/docs/references/react/use-sign-up) diff --git a/.agents/skills/clerk-custom-ui/core-3/show-component.md b/.agents/skills/clerk-custom-ui/core-3/show-component.md new file mode 100644 index 00000000..f01a8b4d --- /dev/null +++ b/.agents/skills/clerk-custom-ui/core-3/show-component.md @@ -0,0 +1,125 @@ +# `` Component + +The `` component conditionally renders content based on authentication state, roles, permissions, billing plans, and features. + +> **Core 2 ONLY (skip if current SDK):** The `` component does not exist in Core 2. Use ``, ``, and `` instead. See migration table below. + +## Import + +```typescript +import { Show } from '@clerk/nextjs' // Next.js +import { Show } from '@clerk/react' // React +import { Show } from '@clerk/react-router' // React Router +import { Show } from '@clerk/expo' // Expo +``` + +## Props + +| Prop | Type | Description | +|------|------|-------------| +| `when` | `string \| object \| function` | Condition for rendering children | +| `fallback?` | `ReactNode` | Content shown when condition fails | +| `treatPendingAsSignedOut?` | `boolean` | Treat pending sessions as signed-out (default: `true`) | + +## `when` Prop Variants + +### Authentication State + +```tsx +// Show content only when signed in + +

Welcome back!

+
+ +// Show content only when signed out + +

Please sign in.

+
+``` + +### Role Check + +```tsx + + + +``` + +### Permission Check + +```tsx + + + +``` + +### Billing Feature Check + +```tsx + + + +``` + +### Billing Plan Check + +```tsx + + + +``` + +### Custom Condition (Function) + +```tsx + has({ role: 'org:admin' }) || has({ permission: 'org:billing:manage' })}> + + +``` + +## Fallback Content + +Show alternative content when the condition fails: + +```tsx +Please sign in to continue.

}> + +
+``` + +## Session Tasks and Pending State + +The `treatPendingAsSignedOut` prop controls how pending sessions (sessions with incomplete tasks) are handled: + +```tsx +// Default: pending sessions are treated as signed-out + + + + +// Treat pending sessions as signed-in (e.g., to show task completion UI) + + + +``` + +## Security Caveat + +**`` only visually hides content** — it remains in browser source. It is not a security boundary. For protecting sensitive data, always verify authentication server-side with `auth()` or use `auth.protect()` in middleware. + +## Migration from Core 2 + +| Core 2 | Current | +|--------|---------| +| `` | `` | +| `` | `` | +| `` | `` | +| `` | `` | +| ` expr}>` | ` expr}>` | +| `` | `` | +| *(no equivalent)* | `` | +| *(no equivalent)* | `` | + +## Docs + +- [Show component reference](https://clerk.com/docs/components/control/show) diff --git a/.agents/skills/clerk-webhooks/.claude-plugin/plugin.json b/.agents/skills/clerk-webhooks/.claude-plugin/plugin.json deleted file mode 100644 index c0ada631..00000000 --- a/.agents/skills/clerk-webhooks/.claude-plugin/plugin.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "clerk-webhooks", - "description": "Clerk webhooks for real-time events and data syncing. Listen for user creation, updates, deletion, and organization events. Build event-driven features like database sync, notifications, integrations.", - "version": "1.0.0", - "author": { - "name": "Clerk", - "email": "support@clerk.com" - }, - "license": "MIT", - "homepage": "https://clerk.com/docs", - "repository": "https://github.com/clerk/skills", - "keywords": [ - "webhooks", - "events", - "sync", - "database", - "notifications", - "integrations" - ], - "category": "authentication" -} diff --git a/.agents/skills/clerk-webhooks/SKILL.md b/.agents/skills/clerk-webhooks/SKILL.md index 509604e7..0233a932 100644 --- a/.agents/skills/clerk-webhooks/SKILL.md +++ b/.agents/skills/clerk-webhooks/SKILL.md @@ -1,133 +1,363 @@ --- name: clerk-webhooks -description: Clerk webhooks for real-time events and data syncing. Listen for user creation, updates, deletion, and organization events. Build event-driven features like database sync, notifications, integrations. +description: Clerk webhooks for real-time events and data syncing. Always output complete, + copy-paste-ready webhook handlers with verifyWebhook(req) verification. Listen for + user creation, updates, deletion, and organization events. Build event-driven features + like database sync, notifications, integrations. allowed-tools: WebFetch license: MIT metadata: author: clerk - version: "1.0.0" + version: 1.2.0 +compatibility: Requires CLERK_WEBHOOK_SECRET (svix signing secret from Clerk dashboard) --- # Webhooks -> **Prerequisite**: Webhooks are asynchronous. Use for background tasks (sync, notifications), not synchronous flows. +Always output complete, working, copy-paste-ready webhook handlers. Never output stubs, placeholders, or partial implementations. Include `verifyWebhook(req)` in every handler. -## Documentation Reference +## CRITICAL: Always Verify Webhooks -| Task | Link | -| ---------------- | ------------------------------------------------------------ | -| Overview | https://clerk.com/docs/guides/development/webhooks/overview | -| Sync to database | https://clerk.com/docs/guides/development/webhooks/syncing | -| Debugging | https://clerk.com/docs/guides/development/webhooks/debugging | -| Event catalog | https://dashboard.clerk.com/~/webhooks (Event Catalog tab) | +**NEVER skip signature verification**, even for notification-only handlers. Always use `verifyWebhook(req)` from `@clerk/nextjs/webhooks`. This uses the `CLERK_WEBHOOK_SECRET` env var automatically. -## Quick Start +## CRITICAL: Make Webhook Route Public -1. Create endpoint at `app/api/webhooks/route.ts` -2. Use `verifyWebhook(req)` from `@clerk/nextjs/webhooks` -3. Dashboard → Webhooks → Add Endpoint -4. Set `CLERK_WEBHOOK_SIGNING_SECRET` in env -5. Make route public (not protected by middleware) +Webhook routes MUST be excluded from Clerk middleware protection. Without this, Clerk returns 401. -## Supported Events +```typescript +// proxy.ts (Next.js <=15: middleware.ts) +import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server' -**User**: `user.created` `user.updated` `user.deleted` +const isPublicRoute = createRouteMatcher(['/api/webhooks(.*)']) -**Organization**: `organization.created` `organization.updated` `organization.deleted` +export default clerkMiddleware((auth, req) => { + if (!isPublicRoute(req)) auth().protect() +}) +``` -**Organization Domain**: `organizationDomain.created` `organizationDomain.updated` `organizationDomain.deleted` +## Complete Webhook Handler (Next.js App Router) -**Organization Invitation**: `organizationInvitation.created` `organizationInvitation.accepted` `organizationInvitation.revoked` +```typescript +// app/api/webhooks/route.ts +import { verifyWebhook } from '@clerk/nextjs/webhooks' +import { NextRequest } from 'next/server' +import { db } from '@/lib/db' + +export async function POST(req: NextRequest) { + // ALWAYS verify - never skip, even for notification-only handlers + let evt + try { + evt = await verifyWebhook(req) // uses CLERK_WEBHOOK_SECRET automatically + } catch (err) { + console.error('Webhook verification failed:', err) + return new Response('Verification failed', { status: 400 }) + } + + if (evt.type === 'user.created') { + const { id, email_addresses, first_name, last_name } = evt.data + const email = email_addresses[0]?.email_address + const name = `${first_name ?? ''} ${last_name ?? ''}`.trim() + await db.users.create({ data: { clerkId: id, email, name } }) + } + + if (evt.type === 'user.updated') { + const { id, email_addresses, first_name, last_name } = evt.data + const email = email_addresses[0]?.email_address + await db.users.update({ where: { clerkId: id }, data: { email, first_name, last_name } }) + } + + if (evt.type === 'user.deleted') { + const { id } = evt.data + await db.users.delete({ where: { clerkId: id } }) + } + + if (evt.type === 'organizationMembership.created') { + const { organization, public_user_data, role } = evt.data + const orgId = organization.id + const userId = public_user_data.user_id + await db.teamMembers.create({ data: { orgId, userId, role } }) + } + + if (evt.type === 'organizationMembership.deleted') { + const { organization, public_user_data } = evt.data + const orgId = organization.id + const userId = public_user_data.user_id + await db.teamMembers.delete({ where: { orgId_userId: { orgId, userId } } }) + } + + return new Response('OK', { status: 200 }) +} +``` -**Organization Membership**: `organizationMembership.created` `organizationMembership.updated` `organizationMembership.deleted` +## Full Example: Welcome Email (Resend) + Slack Notification on user.created -**Roles**: `role.created` `role.updated` `role.deleted` +**ALWAYS use this COMPLETE pattern — never stub it out:** -**Permissions**: `permission.created` `permission.updated` `permission.deleted` +```typescript +// app/api/webhooks/route.ts +import { verifyWebhook } from '@clerk/nextjs/webhooks' +import { NextRequest } from 'next/server' +import { Resend } from 'resend' + +const resend = new Resend(process.env.RESEND_API_KEY) + +export async function POST(req: NextRequest) { + // Step 1: ALWAYS verify the webhook signature - NEVER skip this + let evt + try { + evt = await verifyWebhook(req) // uses CLERK_WEBHOOK_SECRET env var + } catch (err) { + console.error('Webhook verification failed:', err) + return new Response('Verification failed', { status: 400 }) + } + + // Step 2: Listen for user.created event + if (evt.type === 'user.created') { + // Step 3: Extract user email and name from webhook payload + const { id, email_addresses, first_name, last_name } = evt.data + const email = email_addresses[0]?.email_address + const name = `${first_name ?? ''} ${last_name ?? ''}`.trim() + + // Step 4: Call Resend API to send welcome email + await resend.emails.send({ + from: 'noreply@yourdomain.com', + to: email, + subject: 'Welcome!', + html: `

Hi ${name}, welcome to our app!

`, + }) + + // Step 5: Post notification to Slack channel + await fetch(process.env.SLACK_WEBHOOK_URL!, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text: `New user signed up: ${name} (${email})`, + }), + }) + } + + // Always return 200 to acknowledge receipt + return new Response('OK', { status: 200 }) +} +``` -**Session**: `session.created` `session.updated` `session.ended` `session.removed` `session.revoked` `session.pending` +**Also include proxy.ts (Next.js <=15: middleware.ts) to make the route public:** +```typescript +// proxy.ts (Next.js <=15: middleware.ts) +import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server' +const isPublicRoute = createRouteMatcher(['/api/webhooks(.*)']) +export default clerkMiddleware((auth, req) => { + if (!isPublicRoute(req)) auth().protect() +}) +``` -**Communication**: `email.created` `sms.created` +## Full Example: Organization Membership Sync to Database -**Invitations**: `invitation.created` `invitation.accepted` `invitation.revoked` +```typescript +// app/api/webhooks/route.ts +import { verifyWebhook } from '@clerk/nextjs/webhooks' +import { NextRequest } from 'next/server' +import { db } from '@/lib/db' // your database client + +export async function POST(req: NextRequest) { + // ALWAYS verify signature - never skip, even for simple handlers + let evt + try { + evt = await verifyWebhook(req) // uses CLERK_WEBHOOK_SECRET env var + } catch (err) { + console.error('Webhook verification failed:', err) + return new Response('Verification failed', { status: 400 }) + } + + if (evt.type === 'organization.created') { + const { id, name } = evt.data + await db.workspaces.create({ + data: { orgId: id, name, createdAt: new Date() }, + }) + } + + if (evt.type === 'organizationMembership.created') { + // Extract organization ID, user ID, and role from payload + const { organization, public_user_data, role } = evt.data + const orgId = organization.id + const userId = public_user_data.user_id + + // Add to team_members table + await db.team_members.create({ + data: { orgId, userId, role }, + }) + + // Create workspace record for new member + await db.workspaces.create({ + data: { orgId, userId, createdAt: new Date() }, + }) + } + + if (evt.type === 'organizationMembership.deleted') { + // Extract organization ID and user ID from payload + const { organization, public_user_data } = evt.data + const orgId = organization.id + const userId = public_user_data.user_id + + // Remove from team_members table + await db.team_members.delete({ + where: { orgId, userId }, + }) + + // Remove workspace record + await db.workspaces.deleteMany({ + where: { orgId, userId }, + }) + } + + // Return 200 status on success + return new Response('OK', { status: 200 }) +} +``` -**Waitlist**: `waitlistEntry.created` `waitlistEntry.updated` +## Express.js Webhook Handler -Full catalog: Dashboard → Webhooks → Event Catalog +> **CRITICAL**: Use `express.raw()` NOT `express.json()` for webhook routes. Signature verification requires the raw body bytes. `express.json()` parses the body and breaks verification. -## When to Sync +```typescript +import express from 'express' +import { Webhook } from 'svix' + +const app = express() + +// WRONG - breaks verification because it parses the body: +// app.use(express.json()) + +// CORRECT - use raw body for webhook route only: +app.post('/webhooks/clerk', express.raw({ type: 'application/json' }), async (req, res) => { + const webhookSecret = process.env.CLERK_WEBHOOK_SECRET! + + const wh = new Webhook(webhookSecret) + let evt: any + try { + // Svix verifies using raw body bytes + svix headers + evt = wh.verify(req.body, { + 'svix-id': req.headers['svix-id'] as string, + 'svix-timestamp': req.headers['svix-timestamp'] as string, + 'svix-signature': req.headers['svix-signature'] as string, + }) + } catch (err) { + console.error('Webhook verification failed:', err) + return res.status(400).json({ error: 'Verification failed' }) + } + + if (evt.type === 'user.created') { + const { id, email_addresses, first_name, last_name } = evt.data + const email = email_addresses[0]?.email_address + const name = `${first_name ?? ''} ${last_name ?? ''}`.trim() + console.log(`New user: ${name} (${email})`) + } + + if (evt.type === 'user.updated') { + const { id, email_addresses } = evt.data + const email = email_addresses[0]?.email_address + console.log(`User updated: ${id}, email: ${email}`) + } + + if (evt.type === 'user.deleted') { + const { id } = evt.data + console.log(`User deleted: ${id}`) + } + + // Return 200 status on success + return res.status(200).json({ received: true }) +}) +``` -**Do sync when:** +## Payload Field Reference -- Need other users' data (social features, profiles) -- Storing extra custom fields (birthday, country, bio) -- Building notifications or integrations +### User events (`user.created`, `user.updated`, `user.deleted`) +```typescript +const { + id, // Clerk user ID + email_addresses, // array; [0].email_address is primary email + first_name, + last_name, + image_url, + public_metadata, +} = evt.data +``` -**Don't sync when:** +### Organization events (`organization.created`, `organization.updated`, `organization.deleted`) +```typescript +const { + id, // org ID + name, // org name + slug, +} = evt.data +``` -- Only need current user data (use session token) -- No custom fields (Clerk has everything) -- Need immediate access (webhooks are eventual consistency) +### Organization Membership events (`organizationMembership.created`, `organizationMembership.updated`, `organizationMembership.deleted`) +```typescript +const { + organization, // { id, name, ... } + public_user_data, // { user_id, first_name, last_name, ... } + role, // e.g. 'org:admin', 'org:member' +} = evt.data +// Access: organization.id, public_user_data.user_id, role +``` -## Key Patterns +## Supported Events (Full Catalog) -### Make Route Public +**User**: `user.created` `user.updated` `user.deleted` -Webhooks come unsigned. Route must be public: +**Session**: `session.created` `session.ended` `session.pending` `session.removed` `session.revoked` -Ensure `clerkMiddleware()` doesn't protect `/api/webhooks(.*)` path. +**Organization**: `organization.created` `organization.updated` `organization.deleted` -### Verify Webhook +**Organization Membership**: `organizationMembership.created` `organizationMembership.updated` `organizationMembership.deleted` -Use correct import and single parameter: +**Organization Domain**: `organizationDomain.created` `organizationDomain.updated` `organizationDomain.deleted` -```typescript -import { verifyWebhook } from "@clerk/nextjs/webhooks"; -const evt = await verifyWebhook(req); // Pass request directly -``` +**Organization Invitation**: `organizationInvitation.accepted` `organizationInvitation.created` `organizationInvitation.revoked` -### Type-Safe Events +**Communication**: `email.created` `sms.created` -Narrow to specific event: +**Invitation**: `invitation.accepted` `invitation.created` `invitation.revoked` -```typescript -if (evt.type === "user.created") { - // TypeScript knows evt.data structure -} -``` +**Waitlist**: `waitlistEntry.created` `waitlistEntry.updated` -### Handle All Three Events +**Permission**: `permission.created` `permission.updated` `permission.deleted` -Don't only listen to `user.created`. Also handle `user.updated` and `user.deleted`. +**Role**: `role.created` `role.updated` `role.deleted` -### Queue Async Work +**Subscription**: `subscription.created` `subscription.updated` `subscription.active` `subscription.pastDue` -Return 200 immediately, queue long operations: +**Subscription Item**: `subscriptionItem.created` `subscriptionItem.active` `subscriptionItem.updated` `subscriptionItem.canceled` `subscriptionItem.upcoming` `subscriptionItem.ended` `subscriptionItem.abandoned` `subscriptionItem.incomplete` `subscriptionItem.pastDue` `subscriptionItem.freeTrialEnding` -```typescript -await queue.enqueue("process-webhook", evt); -return new Response("Received", { status: 200 }); -``` +**Payment**: `paymentAttempt.created` `paymentAttempt.updated` ## Webhook Reliability -**Retries**: Svix retries failed webhooks for up to 3 days. Return 2xx to succeed, 4xx/5xx to retry. +**Retries**: Svix retries failed webhooks on a set schedule (see [Svix Retry Schedule](https://docs.svix.com/retries)). Return 2xx to succeed, 4xx/5xx to retry. Use the `svix-id` header as an idempotency key to deduplicate retried events. **Replay**: Failed webhooks can be replayed from Dashboard. ## Common Pitfalls -| Symptom | Cause | Fix | -| --------------------- | ---------------------------- | ------------------------------------------------- | -| Verification fails | Wrong import or usage | Use `@clerk/nextjs/webhooks`, pass `req` directly | -| Route not found (404) | Wrong path | Use `/api/webhooks` | -| Not authorized (401) | Route is protected | Make route public | -| No data in DB | Async job pending | Wait/check logs | -| Duplicate entries | Only handling `user.created` | Also handle `user.updated` | -| Timeouts | Handler too slow | Queue async work | +| Symptom | Cause | Fix | +|---------|-------|-----| +| Verification fails (Next.js) | Wrong import or usage | Use `@clerk/nextjs/webhooks`, pass `req` directly | +| Verification fails (Express) | Using `express.json()` | Use `express.raw({ type: 'application/json' })` for webhook route | +| Route not found (404) | Wrong path | Use `/api/webhooks` or preserve existing path | +| Not authorized (401) | Route is protected by middleware | Make route public in `clerkMiddleware()` | +| No data in DB | Async job pending | Wait/check logs | +| Duplicate entries | Only handling `user.created` | Also handle `user.updated` | +| Timeouts | Handler too slow | Queue async work, return 200 first | ## Testing & Deployment **Local**: Use ngrok to tunnel `localhost:3000` to internet. Add ngrok URL to Dashboard endpoint. -**Production**: Update webhook endpoint URL to production domain. Copy signing secret to production env vars. +**Production**: Update webhook endpoint URL to production domain. Copy `CLERK_WEBHOOK_SECRET` to production env vars. + +## See Also + +- `clerk-setup` - Initial Clerk install +- `clerk-orgs` - Org membership events +- `clerk-backend-api` - Sync via direct API calls \ No newline at end of file diff --git a/.agents/skills/clerk-webhooks/evals/evals.json b/.agents/skills/clerk-webhooks/evals/evals.json new file mode 100644 index 00000000..710e507a --- /dev/null +++ b/.agents/skills/clerk-webhooks/evals/evals.json @@ -0,0 +1,61 @@ +{ + "skill_name": "clerk-webhooks", + "evals": [ + { + "id": 1, + "prompt": "every time a user signs up on our app i need to create a row in our users table in postgres (we use prisma). the table has id, email, name, clerkId, createdAt columns. can you set up the webhook handler? we're on next.js app router", + "expected_output": "Creates webhook route handler at app/api/webhooks/route.ts, verifies webhook signature, handles user.created event, inserts into Prisma users table", + "files": [], + "expectations": [ + "Creates route handler at app/api/webhooks/route.ts or similar path", + "Uses POST method handler (not GET)", + "Includes webhook signature verification (verifyWebhook or svix verify)", + "Handles user.created event type and extracts email, name, id from payload", + "Inserts into database using Prisma with the correct column mapping (clerkId = evt.data.id)", + "Mentions that webhook route must be excluded from Clerk middleware (public route)" + ] + }, + { + "id": 2, + "prompt": "i want to send a welcome email via resend when someone creates an account. also want to notify our #new-users slack channel. we already have resend and slack sdks installed", + "expected_output": "Webhook handler for user.created that sends welcome email via Resend and posts to Slack", + "files": [], + "expectations": [ + "Listens for user.created webhook event", + "Extracts user email and name from webhook payload", + "Calls Resend API to send welcome email", + "Posts notification to Slack channel", + "Includes webhook signature verification", + "Does NOT skip verification even though it's a notification-only handler" + ] + }, + { + "id": 3, + "prompt": "we're getting 401 errors on our webhook endpoint. the clerk dashboard shows the webhooks are being sent but our app keeps rejecting them. we're using express not next.js. heres our current code:\n\napp.post('/webhooks/clerk', express.json(), (req, res) => {\n const evt = req.body;\n console.log(evt);\n res.json({ received: true });\n});", + "expected_output": "Identifies missing webhook verification, fixes express body parsing (needs raw body), adds proper Svix verification", + "files": [], + "expectations": [ + "Identifies that express.json() is wrong - needs raw body for signature verification", + "Shows express.raw() or express.text() instead of express.json() for the webhook route", + "Adds webhook signature verification using Svix or @clerk/express webhook utilities", + "Preserves the existing route path /webhooks/clerk", + "Mentions CLERK_WEBHOOK_SECRET environment variable", + "Returns 200 status on success (not just res.json)" + ] + }, + { + "id": 4, + "prompt": "our app has clerk organizations and we need to sync org membership changes to our database. when someone joins or leaves an org we need to update our team_members table. also when an org is created we need to create a workspace record", + "expected_output": "Webhook handler for organizationMembership and organization events with database sync", + "files": [], + "expectations": [ + "Handles organizationMembership.created event (user joins org)", + "Handles organizationMembership.deleted event (user leaves org)", + "Handles organization.created event (new org created)", + "Extracts organization ID, user ID, and role from webhook payloads", + "Includes webhook signature verification", + "Shows database operations for both team_members and workspace tables" + ] + } + ] +} diff --git a/.agents/skills/clerk/.claude-plugin/plugin.json b/.agents/skills/clerk/.claude-plugin/plugin.json deleted file mode 100644 index c0aac790..00000000 --- a/.agents/skills/clerk/.claude-plugin/plugin.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "clerk", - "description": "Clerk authentication router - automatically routes to the right skill based on your task", - "version": "1.0.0", - "author": { - "name": "Clerk", - "email": "support@clerk.com" - }, - "license": "MIT", - "homepage": "https://clerk.com/docs", - "repository": "https://github.com/clerk/skills", - "keywords": ["clerk", "auth", "router"], - "category": "authentication" -} diff --git a/.agents/skills/clerk/SKILL.md b/.agents/skills/clerk/SKILL.md index 24a0cc5b..568b521b 100644 --- a/.agents/skills/clerk/SKILL.md +++ b/.agents/skills/clerk/SKILL.md @@ -1,61 +1,154 @@ --- name: clerk -description: Clerk authentication router. Use when user asks about adding authentication, setting up Clerk, custom sign-in flows, Next.js patterns, organizations, syncing users, or testing. Automatically routes to the specific skill based on their task. +description: Clerk authentication router. Use when user asks about adding authentication, + setting up Clerk, custom sign-in flows, Swift or native iOS auth, native Android + auth, Next.js patterns, React patterns, Vue patterns, Nuxt patterns, Astro patterns, + TanStack Start patterns, Expo patterns, React Router patterns, Chrome Extension patterns, + organizations, billing, subscriptions, payments, pricing, plans, seat-based pricing, + feature entitlements, syncing users, or testing. Automatically routes to the specific + skill based on their task. +license: MIT +metadata: + version: 2.0.0 --- # Clerk Skills Router -Based on what you're trying to do, here's the right skill to use: +## Version Detection + +Check `package.json` to determine the Clerk SDK version. This determines which patterns to use: + +| Package | Core 2 (LTS until Jan 2027) | Current | +|---------|----------------------------|---------| +| `@clerk/nextjs` | v5–v6 | v7+ | +| `@clerk/react` or `@clerk/clerk-react` | v5–v6 | v7+ | +| `@clerk/expo` or `@clerk/clerk-expo` | v1–v2 | v3+ | +| `@clerk/react-router` | v1–v2 | v3+ | +| `@clerk/tanstack-react-start` | < v0.26.0 | v0.26.0+ | + +**Default to current** if the version is unclear or the project is new. Core 2 packages use `@clerk/clerk-react` and `@clerk/clerk-expo` (with `clerk-` prefix); current packages use `@clerk/react` and `@clerk/expo`. + +All skills are written for the current SDK. When something differs in Core 2, it's noted inline with `> **Core 2 ONLY (skip if current SDK):**` callouts. The exception is `clerk-custom-ui`, which has separate `core-2/` and `core-3/` directories for custom flow hooks since those APIs are entirely different between versions. + +--- ## By Task **Adding Clerk to your project** → Use `clerk-setup` - - Framework detection and quickstart - Environment setup, API keys, Keyless flow - Migration from other auth providers **Custom sign-in/sign-up UI** → Use `clerk-custom-ui` - -- Custom authentication flows -- Appearance and styling -- OAuth, magic links, passkeys, MFA +- Custom authentication flows with `useSignIn` / `useSignUp` hooks +- Appearance and styling (themes, colors, layout) +- `` component for conditional rendering **Advanced Next.js patterns** → Use `clerk-nextjs-patterns` - - Server vs Client auth APIs - Middleware strategies - Server Actions, caching - API route protection -**B2B / Organizations** → Use `clerk-orgs` +**React patterns** → Use `clerk-react-patterns` +- Hooks (`useAuth`, `useUser`, `useClerk`) +- Protected routes, auth guards +- Router integration + +**React Router patterns** → Use `clerk-react-router-patterns` +- Loaders & actions with auth +- Route protection +- SSR auth + +**Vue patterns** → Use `clerk-vue-patterns` +- Composables (`useAuth`, `useUser`, `useClerk`) +- Vue Router guards +- Pinia auth store integration + +**Nuxt patterns** → Use `clerk-nuxt-patterns` +- Server middleware auth +- SSR auth with composables +- Server API routes + +**Astro patterns** → Use `clerk-astro-patterns` +- SSR auth pages +- Island components with React +- Middleware & API routes + +**TanStack Start patterns** → Use `clerk-tanstack-patterns` +- Server functions with auth +- Route protection via loaders +- Vinxi server integration + +**Expo patterns** → Use `clerk-expo-patterns` +- Secure token storage +- OAuth deep linking +- Push notifications with auth + +**Chrome Extension patterns** → Use `clerk-chrome-extension-patterns` +- Background scripts auth +- Popup auth flows +- Content scripts with sync host +**B2B / Organizations** → Use `clerk-orgs` - Multi-tenant apps - Organization slugs in URLs - Roles, permissions, RBAC - Member management -**Webhooks** → Use `clerk-webhooks` +**Billing & Subscriptions** → Use `clerk-billing` +- `` component +- Plan and feature gating with `has()` +- Seat-based B2B billing with organizations +- Subscription lifecycle webhooks +- Free trials, invoicing +**Webhooks** → Use `clerk-webhooks` - Real-time events - Data syncing - Notifications & integrations **E2E Testing** → Use `clerk-testing` - - Playwright/Cypress setup - Auth flow testing - Test utilities +**Swift / native iOS auth** → Use `clerk-swift` +- Native iOS Swift and SwiftUI projects +- ClerkKit and ClerkKitUI implementation guidance +- Source-driven patterns from `clerk-ios` + +**Android / native mobile auth** → Use `clerk-android` +- Native Android Kotlin and Jetpack Compose projects +- `clerk-android-api` and `clerk-android-ui` implementation guidance +- Source-driven patterns from `clerk-android` +- Do not use for Expo or React Native projects + +**Backend REST API** → Use `clerk-backend-api` +- Browse API tags and endpoints +- Inspect endpoint schemas +- Execute API requests with scope enforcement + ## Quick Navigation If you know your task, you can directly access: - - `/clerk-setup` - Framework setup -- `/clerk-custom-ui` - Custom flows +- `/clerk-custom-ui` - Custom flows & appearance - `/clerk-nextjs-patterns` - Next.js patterns +- `/clerk-react-patterns` - React patterns +- `/clerk-react-router-patterns` - React Router patterns +- `/clerk-vue-patterns` - Vue patterns +- `/clerk-nuxt-patterns` - Nuxt patterns +- `/clerk-astro-patterns` - Astro patterns +- `/clerk-tanstack-patterns` - TanStack Start patterns +- `/clerk-expo-patterns` - Expo patterns +- `/clerk-chrome-extension-patterns` - Chrome Extension patterns - `/clerk-orgs` - Organizations +- `/clerk-billing` - Billing & subscriptions - `/clerk-webhooks` - Webhooks - `/clerk-testing` - Testing +- `/clerk-swift` - Swift/native iOS +- `/clerk-android` - Native Android +- `/clerk-backend-api` - Backend REST API Or describe what you need and I'll recommend the right one. diff --git a/.agents/skills/db-migration/SKILL.md b/.agents/skills/db-migration/SKILL.md new file mode 100644 index 00000000..cf34c282 --- /dev/null +++ b/.agents/skills/db-migration/SKILL.md @@ -0,0 +1,24 @@ +--- +name: db-migration +description: Generate a Drizzle migration from schema changes and apply it with a mandatory review step +disable-model-invocation: true +--- + +# DB Migration Workflow + +Usage: `/db-migration ` + +## Steps + +1. Run `bun run --cwd server db:generate` to produce the migration SQL +2. Locate the new file in `server/src/db/migrations/` and display its full contents for review +3. **Stop and ask the user to confirm** before proceeding — do not auto-apply +4. On confirmation, run `bun run --cwd server db:migrate` to apply +5. Run `bun run --cwd server type-check` to verify Drizzle-inferred types still compile + +## Rules + +- Never use `db:push` — it bypasses migration history and is for local schema prototyping only +- Migration files are append-only — never edit existing migration SQL files +- If the generated SQL contains a destructive operation (DROP COLUMN, DROP TABLE, TRUNCATE), highlight it explicitly before asking for confirmation +- If type-check fails after migration, surface the error before declaring success diff --git a/.agents/skills/feature-sliced-design/SKILL.md b/.agents/skills/feature-sliced-design/SKILL.md new file mode 100644 index 00000000..43f01797 --- /dev/null +++ b/.agents/skills/feature-sliced-design/SKILL.md @@ -0,0 +1,478 @@ +--- +name: feature-sliced-design +description: > + Official Feature-Sliced Design (FSD) v2.1 skill for applying the methodology + to frontend projects. Use when the task involves organizing project structure + with FSD layers, deciding where code belongs, placing static assets (images, + icons, fonts, PDFs), grouping closely related slices, defining public APIs + and import boundaries, resolving cross-imports or evaluating the @x pattern, + deciding whether to create or remove an entity, evaluating whether the + entities layer is needed at all, deciding whether logic should remain local + or be extracted, migrating from FSD v2.0 or a non-FSD codebase, integrating + FSD with frameworks (Next.js App Router and Pages Router, Nuxt, Vite, + Astro), or implementing common patterns such as authentication, API + handling, Redux, and TanStack Query (React Query) within FSD. +--- + +# Feature-Sliced Design (FSD) v2.1 + +> **Source**: [fsd.how](https://fsd.how) | Strictness can be adjusted based on +> project scale and team context. + +--- + +## 1. Core Philosophy & Layer Overview + +FSD v2.1 core principle: **"Start simple, extract when needed."** + +Place code in `pages/` first. Duplication across pages is acceptable and does +not automatically require extraction to a lower layer. Extract only when the +same code is currently being used in multiple places (not hypothetically), +the usages do not always change together, and the boundary has a focused +responsibility. + +**Not all layers are required.** Most projects can start with only `shared/`, +`pages/`, and `app/`. Add `widgets/`, `features/`, `entities/` only when they +provide clear value. Do not create empty layer folders "just in case." + +FSD uses 6 standardized layers, listed here from highest to lowest: + +```text +app/ → App initialization, providers, routing +pages/ → Route-level composition, owns its own logic +widgets/ → Large composite UI blocks reused across multiple pages +features/ → Reusable user interactions (only when used in 2+ places) +entities/ → Reusable business domain models (only when used in 2+ places) +shared/ → Infrastructure with no business logic (UI kit, utils, API client) +``` + +**Import rule**: A module may only import from layers strictly below it. +Cross-imports between slices on the same layer are forbidden. + +```typescript +// ✅ Allowed +import { Button } from "@/shared/ui/Button"; // features → shared +import { useUser } from "@/entities/user"; // pages → entities + +// ❌ Violation +import { loginUser } from "@/features/auth"; // entities → features +import { likePost } from "@/features/like-post"; // features → features +``` + +**Note**: The `processes/` layer is **deprecated** in v2.1. For migration +details, read `references/migration-guide.md`. + +--- + +## 2. Decision Framework + +When writing new code, follow this tree: + +**Step 1: Where is this code used?** + +- Used in only one page → keep it in that `pages/` slice. +- Used in 2+ pages but duplication is manageable → keeping separate copies + in each page is also valid. +- An entity or feature used in only one page → keep it in that page + (Steiger: `insignificant-slice`). + +**Step 2: Is it reusable infrastructure with no business logic?** + +- UI components → `shared/ui/` +- Utility functions → `shared/lib/` +- API client, route constants → `shared/api/` or `shared/config/` +- Auth tokens, session management → `shared/auth/` +- CRUD operations → `shared/api/` + +**Step 3: Is it a complete user action currently used in multiple places, +with stable boundaries?** + +- Yes → `features/` +- Uncertain, single use, or speculative reuse → keep in the page. + +**Step 4: Is it a business domain model currently used in multiple places, +with stable boundaries?** + +- Yes → `entities/` +- Uncertain, single use, or speculative reuse → keep in the page. + +**Step 5: Is it app-wide configuration?** + +- Global providers, router, theme → `app/` + +**Golden Rule: When in doubt, keep it in `pages/`. Extract only when the +same code is actively used in multiple places and the boundary is clear.** + +--- + +## 3. Quick Placement Table + +| Scenario | Single use | Confirmed multi-use | +| --------------------- | ------------------------------------------- | ------------------------------------- | +| User profile form | `pages/profile/ui/ProfileForm.tsx` | `features/profile-form/` | +| Product card | `pages/products/ui/ProductCard.tsx` | `entities/product/ui/ProductCard.tsx` | +| Product data fetching | `pages/product-detail/api/fetch-product.ts` | `entities/product/api/` | +| Auth token/session | `shared/auth/` (always) | `shared/auth/` (always) | +| Auth login form | `pages/login/ui/LoginForm.tsx` | `features/auth/` | +| CRUD operations | `shared/api/` (always) | `shared/api/` (always) | +| Generic Card layout | | `shared/ui/Card/` | +| Modal manager | | `shared/ui/modal-manager/` | +| Modal content | `pages/[page]/ui/SomeModal.tsx` | | +| Date formatting util | | `shared/lib/format-date.ts` | + +--- + +## 4. Architectural Rules (MUST) + +These rules are the foundation of FSD. Violations weaken the architecture. +If you must break a rule, ensure it is an intentional design decision and +document the reason in code (a comment or ADR). + +### 4-1. Import only from lower layers + +`app → pages → widgets → features → entities → shared`. +Upward imports and cross-imports between slices on the same layer are +forbidden. + +### 4-2. Public API: every slice exports through index.ts + +External consumers may only import from a slice's `index.ts`. Direct imports +of internal files are forbidden. + +```typescript +// ✅ Correct +import { LoginForm } from "@/features/auth"; + +// ❌ Violation: bypasses public API +import { LoginForm } from "@/features/auth/ui/LoginForm"; +``` + +**Shared layer:** Shared has no slices. Define a separate public API per +segment (`shared/ui/index.ts`, `shared/api/index.ts`, etc.) rather than +one top-level `shared/index.ts`. This keeps imports from Shared +organized by intent. + +**RSC / meta-framework exception:** Split entry points +(`index.client.ts`, `index.server.ts`) are permitted. Details and rules: +`references/framework-integration.md`. + +### 4-3. No cross-imports between slices on the same layer + +If two slices on the same layer need to share logic, follow the resolution +order in Section 7. Do not create direct imports. + +### 4-4. Domain-based file naming (no desegmentation) + +Name files after the business domain they represent, not their technical role. +Technical-role names like `types.ts`, `utils.ts`, `helpers.ts` mix unrelated +domains in a single file and reduce cohesion. + +```text +// ❌ Technical-role naming +model/types.ts ← Which types? User? Order? Mixed? +model/utils.ts + +// ✅ Domain-based naming +model/user.ts ← User types + related logic +model/order.ts ← Order types + related logic +api/fetch-profile.ts ← Clear purpose +``` + +### 4-5. No business logic in shared/ + +Shared contains only infrastructure: UI kit, utilities, API client setup, +route constants, assets. Business calculations, domain rules, and workflows +belong in `entities/` or higher layers. + +```typescript +// ❌ Business logic in shared +// shared/lib/userHelpers.ts +export const calculateUserReputation = (user) => { ... }; + +// ✅ Move to the owning domain +// entities/user/lib/reputation.ts +export const calculateUserReputation = (user) => { ... }; +``` + +--- + +## 5. Recommendations (SHOULD) + +### 5-1. Pages First: place code where it is used + +Place code in `pages/` first. Extract to lower layers only when truly needed. +Extraction is a design decision that affects the whole project, so the +threshold should be high. + +**What stays in pages:** + +- Large UI blocks used only in one page +- Page-specific forms, validation, data fetching, state management +- Page-specific business logic and API integrations +- Code that looks reusable but is simpler to keep local + +**Evolution pattern:** Start with everything in `pages/profile/`. When the +same user data is being consumed by another page (not hypothetically), +extract the shared model to `entities/user/`. Keep page-specific API calls +and UI in the page. + +### 5-2. Be conservative with entities + +The entities layer is highly accessible (almost every other layer can import +from it), so changes propagate widely. + +1. **Start without entities.** `shared/` + `pages/` + `app/` is valid FSD. + Thin-client apps rarely need entities. +2. **Do not split slices prematurely.** Keep code in pages. Extract to + entities only when the same code is currently used by multiple + consumers and the boundary is stable. +3. **Business logic does not automatically require an entity.** Keeping types + in `shared/api` and logic in the current slice's `model/` segment may + be sufficient. +4. **Place CRUD in `shared/api/`.** CRUD is infrastructure, not entities. +5. **Place auth data in `shared/auth/` or `shared/api/`.** Tokens and login + DTOs are auth-context-dependent and rarely reused outside authentication. + +For detailed guidance on keeping the entities layer clean (when to skip +it entirely, how to isolate business contexts, why CRUD belongs in +`shared/api`), see `references/excessive-entities.md`. + +### 5-3. Start with minimal layers + +```text +// ✅ Valid minimal FSD project +src/ + app/ ← Providers, routing + pages/ ← All page-level code + shared/ ← UI kit, utils, API client + +// Add layers only when an actual use case requires them: +// + widgets/ ← UI blocks currently reused across multiple pages +// + features/ ← User interactions currently reused across multiple pages +// + entities/ ← Domain models currently reused across pages or features +``` + +### 5-4. Validate with the Steiger linter + +[Steiger](https://github.com/feature-sliced/steiger) is the official FSD +linter. Key rules: + +- **`insignificant-slice`**: Suggests merging an entity/feature into its page + if only one page uses it. +- **`excessive-slicing`**: Suggests merging or grouping when a layer has too + many slices. + +```bash +npm install -D @feature-sliced/steiger +npx steiger src +``` + +--- + +## 6. Anti-patterns (AVOID) + +- **Do not create entities prematurely.** Data structures used in only one + place belong in that place. +- **Do not put CRUD in entities.** Use `shared/api/`. Consider entities only + for complex transactional logic. +- **Do not create a `user` entity just for auth data.** Tokens and login DTOs + belong in `shared/auth/` or `shared/api/`. +- **Do not abuse `@x`.** It is a necessary compromise, not a recommended + pattern. The notation is for the entities layer only, and only when + boundary merge is genuinely impossible. Features and widgets handle + cross-imports through strategies A–D (see Section 7). +- **Do not extract single-use code.** A feature or entity used by only one + page should stay in that page. +- **Do not use technical-role file names.** Use domain-based names + (see Rule 4-4). +- **Be cautious adding UI to entities.** Entity UI tempts cross-imports from + other entities. If you add UI segments to entities, only import them from + higher layers (features, widgets, pages), never from other entities. +- **Do not create god slices.** Slices with excessively broad responsibilities + should be split into focused slices (e.g., split `user-management/` into + `auth/`, `profile-edit/`, `password-reset/`). +- **Do not create a top-level `assets/` segment.** Place static assets next + to the code that uses them. See `references/asset-handling.md`. + +--- + +## 7. Cross-Import Resolution + +Cross-imports are a code smell, not an absolute prohibition. The right +strategy depends on the layer and the situation. + +### Entities layer: prefer boundary merge, @x is last resort + +Cross-imports in `entities` are usually caused by splitting entities too +granularly. Before reaching for `@x`, consider whether the boundaries +should be merged. + +`@x` is a **necessary compromise, not a recommended approach**. Use it only +when boundaries genuinely cannot be merged, and document why. Overuse locks +entity boundaries together and increases refactoring cost. + +### Features and widgets: four strategies (A, B, C, D) + +In `features` and `widgets`, choose based on context: + +- **Strategy A: Slice merge.** Two slices always change together → merge. +- **Strategy B: Push to entities.** Shared domain logic → move to + `entities/`, keep UI in features/widgets. +- **Strategy C: Compose from upper layer (IoC).** The parent (pages or app) + imports both slices and connects them via render props, slots, or DI. +- **Strategy D: Public API access.** When reuse is genuinely unavoidable, + allow it only through the slice's `index.ts`. Never reach into `model/`, + `store/`, or internal files. + +The `@x` notation is for the entities layer only. Features and widgets use +strategies A–D above. + +### Strictness depends on project context + +Cross-imports are dependencies that are generally best avoided, but +sometimes used intentionally. Strictness varies by project context: + +- **Early-stage products** with heavy experimentation: allowing some + cross-imports may be a pragmatic speed trade-off. +- **Long-lived or regulated systems** (fintech, large-scale services): + stricter boundaries pay off in maintainability and stability. + +If a cross-import is introduced, treat it as a deliberate choice and +document the reasoning in code (a comment explaining why other strategies +do not apply). + +For detailed code examples of each strategy, read +`references/cross-import-patterns.md`. + +--- + +## 8. Segments & Structure Rules + +### Standard segments + +Segments group code within a slice by technical purpose: + +- **`ui/`**: UI components, styles, display-related code +- **`model/`**: Data models, state stores, business logic, validation +- **`api/`**: Backend integration, request functions, API-specific types +- **`lib/`**: Internal utility functions for this slice +- **`config/`**: Configuration, feature flags + +### Layer structure rules + +- **App and Shared**: No slices, organized directly by segments. Segments + within these layers may import from each other. +- **Pages, Widgets, Features, Entities**: Slices first, then segments inside + each slice. +- **Slice groups (optional)**: A group folder may contain related slices on + the same layer for navigation purposes only. The group has no segments and + no public API. See `references/layer-structure.md` for details. + +### File naming within segments + +Always use domain-based names that describe what the code is about: + +```text +model/user.ts ← User types + logic + store +model/order.ts ← Order types + logic + store +api/fetch-profile.ts ← Profile fetching +api/update-settings.ts ← Settings update +``` + +If a segment has only one domain concern, the filename may match the slice +name (e.g., `features/auth/model/auth.ts`). + +--- + +## 9. Shared Layer Guide + +Shared contains infrastructure with **no business logic**. It is organized by +segments only (no slices). Segments within shared may import from each other. + +**Allowed in shared:** + +- `ui/`: UI kit (Button, Input, Modal, Card) +- `lib/`: Utilities (formatDate, debounce, classnames) +- `api/`: API client, route constants, CRUD helpers, base types +- `auth/`: Auth tokens, login utilities, session management +- `config/`: Environment variables, app settings +- `assets/`: Branding assets shared across the app (use sparingly; see + `references/asset-handling.md`) + +Shared **may** contain application-aware code (route constants, API endpoints, +branding assets, common types). It must **never** contain business logic, +feature-specific code, or entity-specific code. + +--- + +## 10. Quick Reference + +- **Import direction**: `app → pages → widgets → features → entities → shared` +- **Minimal FSD**: `app/` + `pages/` + `shared/` +- **Create entities when**: the same business domain model is currently + used across multiple pages, features, or widgets, with stable + boundaries. +- **Create features when**: the same user interaction is currently used + across multiple pages or widgets, with stable boundaries. +- **Breaking rules**: Only as an intentional design choice. Document the + reason in code (comment or ADR). +- **Cross-import resolution (entities)**: Merge boundaries first; `@x` is a + necessary compromise, not recommended. +- **Cross-import resolution (features/widgets)**: Strategy A (merge), B + (push to entities), C (compose from upper layer), or D (Public API). + The `@x` notation is for entities only. +- **File naming**: Domain-based (`user.ts`, `order.ts`). Never technical-role + (`types.ts`, `utils.ts`). +- **Asset placement**: Place next to the code that uses them; reuse goes to + `shared/ui/`; global stylesheets and fonts go to `app/`. +- **Slice groups**: Optional navigation aid for large layers; group folder + has no segments and no public API. +- **Processes layer**: Deprecated. See `references/migration-guide.md`. + +--- + +## 11. Conditional References + +Read the following reference files **only** when the specific situation applies. +Do **not** preload all references. + +- **When creating, reviewing, or reorganizing folder and file structure** for + FSD layers and slices, including grouping closely related slices into a + parent folder for navigation (e.g., "set up project structure", "where does + this folder go", "how do I group these payment entities"): + → Read `references/layer-structure.md` + +- **When resolving cross-import issues** between slices on the same layer, + evaluating the `@x` pattern, choosing between Strategy A/B/C/D for + features and widgets, or deciding whether boundaries should be merged: + → Read `references/cross-import-patterns.md` + +- **When deciding whether to create or remove an entity**, dealing with too + many entities, evaluating whether to skip the entities layer entirely, + placing CRUD operations, deciding where authentication data belongs, or + isolating business contexts to avoid `@x` chains: + → Read `references/excessive-entities.md` + +- **When deciding where to place static assets** (images, icons, fonts, + PDFs, stylesheets) for a single slice, for sharing across slices, or + globally: + → Read `references/asset-handling.md` + +- **When migrating** from FSD v2.0 to v2.1, converting a non-FSD codebase to + FSD, or deprecating the processes layer: + → Read `references/migration-guide.md` + +- **When integrating FSD with a specific framework** (Next.js with App Router + or Pages Router, Nuxt, Vite, CRA, Astro) for wiring routes to FSD pages, + placing middleware/instrumentation files, structuring API route handlers, + or configuring path aliases: + → Read `references/framework-integration.md` + +- **When implementing concrete code patterns** for authentication, API request + handling, type definitions, or state management (Redux, TanStack Query / + React Query, including query factories, infinite scroll, Suspense mode, + and `useMutationState`) within FSD structure: + → Read `references/practical-examples.md` + Note: If you already loaded `layer-structure.md` in this conversation, + avoid loading this file simultaneously. Address structure first, then load + patterns in a follow-up step if needed. diff --git a/.agents/skills/feature-sliced-design/references/asset-handling.md b/.agents/skills/feature-sliced-design/references/asset-handling.md new file mode 100644 index 00000000..4628e44b --- /dev/null +++ b/.agents/skills/feature-sliced-design/references/asset-handling.md @@ -0,0 +1,183 @@ +# Asset Handling + +How to place static assets (images, icons, fonts, PDFs, stylesheets) inside an +FSD project. Assets follow the same placement rules as code: group by use +case, not by type, and keep them next to the code that uses them. + +> **Caution:** A custom top-level `assets` segment that aggregates all static +> files is **not recommended**. It violates the FSD principles of high +> cohesion and locality of changes. Place assets where they are used. + +--- + +## Decision Tree + +1. **Used by exactly one slice?** Keep the asset inside that slice, usually + in the `ui/` segment, or in `model/` if it is part of business logic. +2. **Reused across the app (icons, placeholder images)?** Move to + `shared/ui/`. +3. **Global stylesheet, font, or app-level resource?** Place in the `app/` + layer (`app/styles/`, `app/fonts/`). +4. **Served as-is by the bundler (favicon, robots.txt)?** Use the framework's + `public/` folder. The `public/` folder is not part of FSD and does not + conflict with FSD layers. + +--- + +## Slice-specific Assets + +When an asset belongs to one page, widget, or feature, keep it inside that +slice. The asset lives next to the component that renders it: + +```text +pages/ + home/ + ui/ + hero-image.jpg ← Used only by HomePage + HomePage.tsx + index.ts +``` + +If a slice uses many static images, group them in a subfolder of `ui/`: + +```text +pages/ + home/ + ui/ + previews/ + cake.jpg + pizza.jpg + sushi.jpg + HomePage.tsx + index.ts +``` + +### Non-UI Assets + +Some assets are not part of the UI but are coupled to business logic. For +example, a PDF template used to generate invoices. Place these in the +`model/` segment alongside the logic that consumes them, not in `ui/`: + +```text +features/ + billing/ + model/ + invoice-template.pdf ← Coupled to create-invoice.ts + create-invoice.ts + index.ts +``` + +The principle is locality of changes: if you delete the slice, every file it +owns goes with it. An asset that lives in business logic should sit next to +that logic. + +--- + +## Shared Assets + +When the same asset appears across multiple slices, move it to `shared/ui/`. +Place reusable images in a topical subfolder, or place a single asset next to +the shared component that uses it: + +```text +shared/ + ui/ + placeholders/ ← Reused placeholder images + cake.jpg + pizza.jpg + Dropdown.tsx + chevron.svg ← Used only by Dropdown, kept next to it +``` + +A single icon used by exactly one component in the UI kit stays next to that +component. A library of icons or images reused across many components goes +in a topical subfolder. + +--- + +## Global Assets + +Global stylesheets and fonts belong in the `app/` layer because they are +imported by the application entrypoint, not by individual slices: + +```text +app/ + styles/ + reset.css + global.css + fonts/ + inter.woff2 + main.ts +``` + +Theme variables, CSS resets, and font registrations are app-wide concerns. +They bootstrap the application's visual layer the same way providers +bootstrap the runtime layer. + +--- + +## Public Folder + +Most bundlers expose a `public/` folder at the project root. Files here are +served as-is, without bundling or hashing. + +- Vite, Next.js, Nuxt: `public/` at the project root. +- Astro: `public/` at the project root (path is fixed and cannot be changed). + +`public/` is not part of FSD. It does not collide with FSD layers and does +not need to live under `src/`. Use it for files that must be served at fixed +URLs: favicon, `robots.txt`, `sitemap.xml`, OG images, and similar. + +```text +public/ + favicon.ico + robots.txt + og-image.png +src/ + app/ + pages/ + shared/ +``` + +Some projects keep a project-local `app/public/` folder when the bundler +allows assets to live alongside the entrypoint. Both layouts are valid. + +--- + +## Summary Table + +| Asset | Location | +| -------------------------------------- | ----------------------------------------- | +| Image used by one page/widget/feature | Inside the slice's `ui/` segment | +| PDF or template tied to business logic | Inside the slice's `model/` segment | +| Icon reused across the app | `shared/ui/` (topical subfolder if many) | +| Icon used by exactly one shared kit UI | Next to that component in `shared/ui/` | +| Global CSS reset, theme variables | `app/styles/` | +| Web fonts | `app/fonts/`, `public/`, or `app/public/` | +| Favicon, robots.txt, sitemap | `public/` (or `app/public/`) | + +--- + +## Anti-patterns + +- **Do not create a top-level `assets/` segment** that holds all images, + fonts, and icons. It breaks cohesion and forces consumers to import from a + folder unrelated to the code they are working on. +- **Do not extract a slice-local asset to `shared/` "in case" it gets + reused.** Move it only when actual reuse appears. +- **Do not place CSS modules in an `assets/` folder.** A component's + stylesheet belongs next to that component in `ui/`. +- **Do not name an FSD segment `public`.** The framework's `public/` folder + is reserved and lives outside `src/`. +- **Do not split assets and the components that use them.** A page that + ships a hero image should keep that image in the page so removing the page + removes the image. + +--- + +## See Also + +- `references/layer-structure.md`: segment rules and layer organization +- [Desegmentation](https://fsd.how/docs/guides/issues/desegmented/): why + technical-role grouping (including a generic `assets/` segment) hurts + cohesion diff --git a/.agents/skills/feature-sliced-design/references/cross-import-patterns.md b/.agents/skills/feature-sliced-design/references/cross-import-patterns.md new file mode 100644 index 00000000..a76ce4e6 --- /dev/null +++ b/.agents/skills/feature-sliced-design/references/cross-import-patterns.md @@ -0,0 +1,374 @@ +# Cross-Import Resolution Patterns + +How to resolve cross-imports between slices on the same layer. Cross-imports +are a code smell, not an absolute prohibition. The strategies below are +ordered, but the right choice depends on the project context. + +## What is a cross-import? + +A cross-import is an import between different slices within the same layer. +For example: + +- importing `features/product` from `features/cart` +- importing `widgets/sidebar` from `widgets/header` + +The `shared` and `app` layers do not have slices, so imports within those +layers are not cross-imports. + +## Why is this a code smell? + +Cross-imports blur domain boundaries and introduce implicit dependencies. +Four concrete problems: + +1. **Unclear ownership and responsibility.** When `cart` imports from + `product`, it becomes unclear which slice owns the shared logic. + Changes to `product`'s internal implementation can break `cart` + without warning. This makes bugs harder to localize and code harder + to reason about. +2. **Reduced isolation and testability.** A core benefit of sliced + architecture is that each slice can be developed, tested, and deployed + independently. Cross-imports break this isolation. Testing `cart` now + requires setting up `product`, and changes in one slice can cause + unexpected test failures in another. +3. **Increased cognitive load.** Working on `cart` now requires accounting + for how `product` is structured. As cross-imports accumulate, tracing + the impact of a change requires following more code across slice + boundaries. +4. **Path to circular dependencies.** Cross-imports often start as one-way + dependencies but evolve into bidirectional ones (A imports B, B imports + A). This locks slices together and makes refactoring increasingly costly. + +## Entities layer: prefer boundary merge over @x + +Cross-imports in `entities` are usually caused by splitting entities too +granularly. Before reaching for `@x`, consider whether the boundaries should +be merged instead. + +The `@x` notation is available as a dedicated cross-import surface for +`entities`, but it should be treated as a **last resort**, a **necessary +compromise**, not a recommended approach. Think of `@x` as an explicit +gateway for unavoidable domain references, not a general-purpose reuse +mechanism. Overuse locks entity boundaries together and makes refactoring +more costly over time. + +### How @x works (when boundary merge is genuinely impossible) + +Each entity exposes a special `@x/` directory containing files named after +the consuming entity. This makes the cross-import explicit and auditable. + +**Direction rule:** in the path `entities/A/@x/B`, **A is the producer and +B is the consumer**. Read it as "A crossed with B": the file `A/@x/B.ts` +is the public API that A exposes specifically for B. So in the example +below, `entities/user/@x/order.ts` is what `user` exposes to `order`, and +`order` imports from it. + +```text +entities/ + user/ + @x/ + order.ts ← Exposed specifically for the order entity + model/ + user.ts + index.ts + order/ + model/ + order-summary.ts ← Imports from user/@x/order + index.ts +``` + +```typescript +// entities/user/@x/order.ts: exposes only what order needs +export { getUserDisplayName } from "../model/user"; + +// entities/order/model/order-summary.ts +import { getUserDisplayName } from "@/entities/user/@x/order"; +``` + +### Rules when using @x + +1. Document why `@x` is needed and why merging boundaries does not apply. +2. Review periodically. Requirements change and `@x` may become unnecessary. +3. Minimize the surface area of `@x` exports. +4. Only between entities. Features and widgets should use Strategy C or D + below, not `@x`. + +## Features and widgets: four strategies + +In `features` and `widgets`, multiple strategies are available depending on +project context. Cross-imports here are not always forbidden; they are +dependencies that should be deliberate. The four strategies below are +listed in preferred order, but each fits different situations. + +### Strategy A: Slice merge + +If two slices are not truly independent and always change together, merge +them into a single larger slice. + +```text +// Before: two features that always change together +features/profile/ +features/profile-settings/ + +// After: one cohesive feature +features/profile/ + ui/ + Profile.tsx + ProfileSettings.tsx + model/ + profile.ts + profile-settings.ts + index.ts +``` + +If two slices keep cross-importing each other and effectively move as one +unit, they are likely one feature in practice. Merging is often the simpler +and cleaner choice. + +### Strategy B: Push shared domain flows down into entities + +If multiple features share a domain-level flow, move that flow into a domain +slice inside `entities`. Key principles: + +- `entities` contains domain types and domain logic only. +- UI remains in `features` and `widgets`. +- Features import and use the domain logic from `entities`. + +For example, if both `features/auth` and `features/profile` need session +validation, place session-related domain functions in `entities/session` +and reuse them from both features. + +```text +entities/ + session/ + model/ + validate-session.ts + session.ts + index.ts + +features/ + auth/ + ui/LoginForm.tsx + model/login.ts ← imports validateSession from entities/session + index.ts + profile/ + ui/ProfilePanel.tsx + model/profile.ts ← imports validateSession from entities/session + index.ts +``` + +### Strategy C: Compose from an upper layer (IoC) + +Instead of connecting slices within the same layer via cross-imports, +compose them at a higher level (`pages` or `app`). The upper layer assembles +and connects the slices; the slices themselves do not know about each other. + +Common Inversion of Control techniques: + +- **Render props (React)**: pass components or render functions as props. +- **Slots (Vue)**: use named slots to inject content from parent components. +- **Dependency injection**: pass dependencies through props or context. + +#### Basic composition (React) + +```typescript +// features/user-profile/index.ts +export { UserProfilePanel } from "./ui/UserProfilePanel"; + +// features/activity-feed/index.ts +export { ActivityFeed } from "./ui/ActivityFeed"; + +// pages/UserDashboardPage.tsx +import { UserProfilePanel } from "@/features/user-profile"; +import { ActivityFeed } from "@/features/activity-feed"; + +export const UserDashboardPage = () => ( +
+ + +
+); +``` + +`features/user-profile` and `features/activity-feed` do not know about each +other. The page composes them. + +#### Render props (React) + +When one feature needs to render content from another, use render props to +invert the dependency: + +```typescript +// features/comment-list/ui/CommentList.tsx +interface CommentListProps { + comments: Comment[]; + renderUserAvatar?: (userId: string) => React.ReactNode; +} + +export const CommentList = ({ comments, renderUserAvatar }: CommentListProps) => ( +
    + {comments.map((comment) => ( +
  • + {renderUserAvatar?.(comment.userId)} + {comment.text} +
  • + ))} +
+); + +// pages/PostPage.tsx +import { CommentList } from "@/features/comment-list"; +import { UserAvatar } from "@/features/user-profile"; + +export const PostPage = () => ( + } + /> +); +``` + +`CommentList` does not import from `user-profile`. The page injects the +avatar component. + +#### Slots (Vue) + +Vue's slot system provides a natural way to compose features without +cross-imports: + +```vue + + + + + +``` + +### Strategy D: Cross-feature reuse only via Public API + +If strategies A-C do not fit and cross-feature reuse is genuinely +unavoidable, allow it only through an explicit Public API (exported hooks +or UI components). Do not access another slice's `store`, `model`, or +internal implementation. + +Unlike strategies A-C which aim to eliminate cross-imports, this strategy +accepts them while minimizing risk through strict boundaries. + +```typescript +// features/auth/index.ts +export { useAuth } from "./model/use-auth"; +export { AuthButton } from "./ui/AuthButton"; + +// features/profile/ui/ProfileMenu.tsx +import { useAuth, AuthButton } from "@/features/auth"; + +export const ProfileMenu = () => { + const { user } = useAuth(); + if (!user) return ; + return
{user.name}
; +}; +``` + +The boundary holds: `features/profile` cannot import from +`@/features/auth/model/internal/*`. Only what `features/auth` explicitly +exposes through `index.ts` is reachable. + +The `@x` notation is for the entities layer only. Features and widgets use +strategies A through D above; their access path is the standard public API +(`index.ts`), not a dedicated cross-import surface. + +## When to treat a cross-import as a problem + +After reviewing these strategies, the question is: when is a cross-import +acceptable to keep, and when should it be treated as a code smell and +refactored? + +Common warning signs: + +- Directly depending on another slice's `store`, `model`, or business logic +- Deep imports into another slice's internal files (bypassing the public API) +- Bidirectional dependencies (A imports B, and B imports A) +- Changes in one slice frequently breaking another slice +- Flows that should be composed in `pages` or `app`, but are forced into + cross-imports within the same layer + +When these signals appear, treat the cross-import as a code smell and apply +one of the strategies above. + +## Strictness depends on project context + +The strictness of cross-import enforcement depends on the project: + +- In **early-stage products** with heavy experimentation, allowing some + cross-imports may be a pragmatic speed trade-off. +- In **long-lived or regulated systems** (fintech, large-scale services), + stricter boundaries pay off in maintainability and stability. + +Cross-imports are not an absolute prohibition. They are dependencies that +are generally best avoided, but sometimes used intentionally. If a +cross-import is introduced: + +- Treat it as a deliberate architectural choice. +- Document the reasoning in code (a comment explaining why other + strategies do not apply). +- Revisit it periodically as the system evolves; if requirements change, + the cross-import may no longer be needed. + +## Decision flow for AI agents + +```text +Two slices on the same layer need to share code. + │ + ├─ ENTITIES layer? + │ ├─ Can boundaries be merged into one entity? + │ │ └─ YES → Merge. Stop. + │ └─ Boundaries must stay separate? + │ └─ Use @x as last resort. Document why merge is not possible. + │ + └─ FEATURES or WIDGETS layer? + ├─ Strategy A: Do they always change together? + │ └─ YES → Merge slices. + │ + ├─ Strategy B: Is the shared part domain-only logic? + │ └─ YES → Push down to entities. Keep UI in features. + │ + ├─ Strategy C: Can the connection be assembled by a higher layer? + │ └─ YES → Compose in pages or app via render props, slots, or DI. + │ + └─ Strategy D: Is reuse genuinely unavoidable and the access surface + limited to a Public API? + └─ YES → Allow, but only through index.ts. Never reach into + model/, store/, or internal files. Do not use @x in + features or widgets. +``` + +## Anti-patterns + +- **Reaching for `@x` in features or widgets.** `@x` is for entities only. + Use Strategy C (compose) or D (Public API) instead. +- **Treating `@x` as a clean solution.** It is a compromise. If you find + yourself adding multiple `@x` files between the same entities, the + boundaries are probably wrong. Merge them. +- **Bypassing the Public API to access internals.** Even when Strategy D is + in use, importing from `@/features/auth/model/internal/*` defeats the + purpose. Restrict yourself to what `index.ts` exports. +- **Bidirectional cross-imports.** A imports B and B imports A is almost + always a sign that the slices should be merged. + +## See also + +- `references/excessive-entities.md`: prevent the conditions that lead to + entity-layer cross-imports in the first place. +- `references/layer-structure.md`: layer rules and import directions. diff --git a/.agents/skills/feature-sliced-design/references/excessive-entities.md b/.agents/skills/feature-sliced-design/references/excessive-entities.md new file mode 100644 index 00000000..24112b5c --- /dev/null +++ b/.agents/skills/feature-sliced-design/references/excessive-entities.md @@ -0,0 +1,287 @@ +# Excessive Entities + +How to keep the `entities` layer clean and avoid over-extracting business +logic into entities. Excessive entities cause ambiguity (what code belongs +where), coupling, and constant import dilemmas as code scatters across +sibling entities. + +## Why this matters + +The `entities` layer is one of the lower layers and is widely accessible. +Every layer except `shared` can import from it. That global nature means +changes to `entities` propagate widely, requiring careful design to avoid +costly refactors. Adding an entity is cheap; removing one after many +consumers depend on it is expensive. + +## How to keep entities clean + +### 0. Consider having no entities layer + +An FSD application without an `entities` layer is still FSD. Skipping the +layer simplifies the architecture and keeps it available for future scaling. + +**Thin clients** (where the backend handles most data processing and the +client mostly exchanges data) usually do not need an entities layer. +**Thick clients** (significant client-side business logic) are better +candidates for entities. + +The classification is not strictly binary. Different parts of the same +application may behave as thick or thin clients. + +```text +// Thin client without entities layer (still valid FSD) +src/ + app/ + pages/ + dashboard/ + profile/ + shared/ + api/ + ui/ +``` + +### 1. Avoid preemptive slicing + +FSD v2.1 encourages **deferred decomposition** of slices. Place code in the +`model` segment of the consuming page, widget, or feature first. Move it to +`entities` later, when business requirements stabilize and reuse is +confirmed across multiple consumers. + +The later code moves to `entities`, the less dangerous the refactor. Code +in `entities` can affect every higher-layer slice that imports it. + +```text +// Iteration 1: code lives where it is used +pages/profile/ + model/ + profile-validation.ts ← page-specific for now + +// Iteration 2 (after the same logic is needed in 2+ places): +entities/profile/ + model/ + profile-validation.ts ← extracted only after reuse is real +``` + +### 2. Avoid unnecessary entities + +Do not create an entity for every piece of business logic. Use types from +`shared/api` and place logic in the `model` segment of the current slice. +For genuinely reusable business logic, use the `model` segment within an +entity slice while keeping data definitions in `shared/api`. + +```text +shared/ + api/ + endpoints/ + order.ts ← OrderDto type and request functions + +entities/ + order/ + model/ + apply-discount.ts ← Business logic that uses OrderDto + index.ts +``` + +The DTO lives in `shared/api/endpoints/order.ts`. Business logic that +operates on it (calculating discounts, applying promotions) lives in +`entities/order/model/`. Do not mirror every API endpoint with a +corresponding entity. + +### 3. Exclude CRUD operations from entities + +CRUD operations involve boilerplate code without significant business +logic. Putting them in `entities` clutters the layer and obscures the code +that genuinely matters. Place CRUD in `shared/api`: + +```text +shared/ + api/ + client.ts + endpoints/ + order.ts ← getOrder, createOrder, updateOrder, deleteOrder + products.ts ← Standard CRUD for products + cart.ts ← Standard CRUD for cart + index.ts +``` + +For complex CRUD with atomic updates, rollbacks, or transactions, evaluate +whether the operation is genuinely business logic. If so, the `entities` +layer may be appropriate. If not, keep it in `shared/api`. + +### 4. Store authentication data in shared + +Prefer `shared` over creating a `user` entity for auth tokens and session +DTOs. These are context-specific to authentication and unlikely to be +reused outside that scope. Wrapping a login response in a `user` entity +also tends to drag entities into cross-layer imports or `@x` chains, +complicating the architecture. + +The Auth guide also documents **In Entities** (a `user` entity) as a +valid placement when the project already has an entities layer and the +data is genuinely reused. **In Pages/Widgets** is discouraged for both +guides. + +**`shared/auth` (or `shared/api`) is the recommended default.** Choose +it when: + +- The project has no entities layer yet +- Auth state is just a token plus minimal user info (id, email, role) +- Token management logic (refresh, expiration) is the main concern, not + user profile data + +```text +shared/ + auth/ + use-auth.ts ← Token + minimal user info + index.ts + api/ + client.ts ← API client reads token from shared/auth + endpoints/ + order.ts + index.ts +``` + +This approach pairs naturally with an API client middleware that injects +the token into authenticated requests. + +**A `user` entity is the right call when:** + +- The project already has an entities layer +- Auth and profile data are tightly coupled (current user info is reused + across pages for non-auth purposes like comments, posts, mentions) +- Token management has complex business logic (invalidation policies, + multi-device session tracking) that benefits from co-location with the + user model + +```text +entities/ + user/ + model/ + current-user.ts ← Token + full user model + business logic + user.ts ← Generic user type, used for other users too + api/ + get-current-user.ts + index.ts +``` + +When using the entity approach, the API client (in `shared/api`) needs +access to the token without violating the import rule. The official Auth +guide describes three solutions: pass the token manually on each request, +expose it through a context or `localStorage` with the key kept in +`shared/api`, or inject the token into the API client whenever the entity +store updates. + +**Pages and widgets are discouraged.** Avoid placing the token store in a +page's `model/` segment or in a widget. App-wide state belongs in Shared +or Entities, not in route-bound or block-bound layers. + +### Decision summary + +| Project state | Recommended location | +| --- | --- | +| No entities layer (yet), simple token + minimal user info | `shared/auth` | +| Entities layer exists, auth and profile tightly coupled | `entities/user` | +| Complex token logic, no profile reuse yet | `shared/auth` (split from `shared/api`) | +| Token storage in a single page or widget | Avoid; promote to Shared or Entities | + +A `user` entity created **only** to wrap a login response is premature. +Wait until profile data is consumed for non-auth purposes (avatars in +comments, names in posts) before introducing the entity. + +### 5. Minimize cross-imports + +FSD permits cross-imports between entities via `@x`, but they introduce +technical issues including circular dependencies. Design entities within +**isolated business contexts** so cross-imports become unnecessary. + +**Non-isolated context (avoid):** + +```text +entities/ + order/ + @x/ + model/ + order-item/ + @x/ + model/ + order-customer-info/ + @x/ + model/ +``` + +Three sibling entities all referencing each other through `@x`. This is a +sign that the boundaries are wrong. + +**Isolated context (preferred):** + +```text +entities/ + order-info/ + model/ + order-info.ts ← order, items, and customer info together + index.ts +``` + +One entity encapsulates the related logic. No `@x`, no cross-imports, +no circular dependency risk. + +The general rule: when several entities have `@x` dependencies on each +other, treat that as a signal to merge the boundaries, not as something to +manage. + +## Decision tree for AI agents + +```text +A new piece of business logic needs a home. + │ + ├─ Is the project a thin client? + │ └─ YES → Skip entities. Place in shared/ + page model. + │ + ├─ Is the logic used in only one place right now? + │ └─ YES → Keep in the consuming slice's model/. Defer extraction. + │ + ├─ Is it a CRUD operation without business meaning? + │ └─ YES → shared/api/endpoints/.ts + │ + ├─ Is it auth data (tokens, session, login DTOs)? + │ ├─ Project has no entities layer yet? + │ │ └─ YES → shared/auth/ + │ ├─ Auth and profile data tightly coupled, entities layer exists? + │ │ └─ YES → entities/user/ + │ └─ Otherwise → shared/auth/ (default). + │ Avoid placing in a page or widget. + │ + ├─ Is it just a TypeScript type for an API response? + │ └─ YES → shared/api/. No entity needed for types alone. + │ + └─ Is it reusable domain logic confirmed in 2+ consumers? + └─ YES → Create entities//model/. + Verify the boundary is isolated and does not require @x + to communicate with sibling entities. +``` + +## Anti-patterns + +- **Creating entities preemptively.** Wait for confirmed reuse in 2+ + consumers, not anticipated reuse. +- **Mirroring every API endpoint with an entity.** API endpoints belong in + `shared/api`. Entities exist for business logic, not for paralleling the + backend structure. +- **Creating a `user` entity *only* to wrap a login response.** A `user` + entity is justified when profile data is reused across non-auth flows + (avatars in comments, names in posts) or when token logic is genuinely + tied to user business logic. Until that reuse appears, `shared/auth` + is simpler. Storing tokens in a page or widget is discouraged regardless + of the project shape. +- **Splitting one domain into many entities (`order`, `order-item`, + `order-customer-info`).** This produces `@x` chains. Merge into a single + isolated context (`order-info` or `order`). +- **Putting CRUD wrappers in entities.** They clutter the layer. CRUD goes + in `shared/api/endpoints/`. + +## See also + +- `references/cross-import-patterns.md`: how to handle cross-imports when + they appear, and why `@x` is a last resort. +- `references/layer-structure.md`: layer responsibilities and the entities + segment shape. diff --git a/.agents/skills/feature-sliced-design/references/framework-integration.md b/.agents/skills/feature-sliced-design/references/framework-integration.md new file mode 100644 index 00000000..a28c1ac7 --- /dev/null +++ b/.agents/skills/feature-sliced-design/references/framework-integration.md @@ -0,0 +1,496 @@ +# Framework Integration + +How to set up FSD within specific frameworks. Covers directory placement, +routing integration, and framework-specific path alias configuration. + + +## General Principle + +Place FSD layers inside `src/` to avoid naming conflicts with framework +directories. The FSD `app/` and `pages/` layers are **not** the same as +framework directories with the same names (e.g., Next.js `app/`). + +All FSD projects follow the same `@//*` path alias convention. The +exact configuration differs by framework. See each framework section +below. Astro is the one exception, using a single `@/*` alias instead. + + +## Next.js + +FSD is compatible with both the App Router and the Pages Router. The main +conflict is that Next.js owns the `app/` and `pages/` folder names, and both +collide with FSD layer names. Resolve the conflict by moving the Next.js +routing folders to the project root and importing FSD pages from `src/` into +them. + +### App Router + +The Next.js `app/` folder lives at the project root. **Always create an +empty `pages/` folder at the project root as well, even when you only use +the App Router.** Without it, Next.js tries to use `src/pages/` as the Pages +Router and the build breaks. Add a `pages/README.md` explaining why the +folder exists. + +#### Directory structure + +```text +my-nextjs-project/ + app/ ← Next.js App Router (routing only) + layout.tsx + page.tsx + profile/ + page.tsx + api/ + get-example/ + route.ts + pages/ ← Empty, required even for App Router + README.md + src/ + app/ ← FSD app layer + providers/ + index.tsx ← All providers (QueryClient, theme, etc.) + styles/ + globals.css + api-routes/ ← Route Handler implementations (see below) + index.ts + get-example-data.ts + pages/ ← FSD pages layer + home/ + ui/HomePage.tsx + index.ts + profile/ + ui/ProfilePage.tsx + model/profile.ts + api/fetch-profile.ts + index.ts + widgets/ ← FSD widgets layer (when needed) + features/ ← FSD features layer (when needed) + entities/ ← FSD entities layer (when needed) + shared/ ← FSD shared layer + ui/ + lib/ + api/ + db/ ← Database queries (see below) +``` + +#### Wiring Next.js routes to FSD pages + +```typescript +// app/layout.tsx +import { Providers } from '@/app/providers'; +import '@/app/styles/globals.css'; + +export default function RootLayout({ children }) { + return ( + + {children} + + ); +} + +// app/example/page.tsx: re-export the FSD page (component + metadata) +export { ExamplePage as default, metadata } from '@/pages/example'; +``` + +Always re-export both the component and `metadata`. Route files contain no logic. + +### Pages Router + +The Pages Router uses `pages/` at the project root. The FSD `src/` tree is +unchanged. Routes re-export from `src/pages/`: + +```text +my-nextjs-project/ + pages/ ← Next.js Pages Router (routing only) + _app.tsx + api/example.ts ← API route re-export + example/index.tsx + src/ + app/ + custom-app/ ← Custom App component + api-routes/ ← Route Handler implementations + pages/ + example/ + ui/example.tsx + index.ts +``` + +```typescript +// pages/example/index.tsx +export { Example as default } from '@/pages/example'; + +// pages/_app.tsx: re-export the custom App from src/app/custom-app +export { App as default } from '@/app/custom-app'; +``` + +The custom App component itself lives in `src/app/custom-app/` and exports +`App` from its public API like any other FSD slice. + +### Middleware and instrumentation + +`middleware.js` and `instrumentation.js` must live at the **project root**, +next to the Next.js `app/` and `pages/` folders. Next.js will not detect +them inside `src/`. + +### Route Handlers (API routes) + +Use a dedicated `api-routes` segment in the FSD `app/` layer +(`src/app/api-routes/`) to host the actual request handlers. The Next.js +`app/api/*/route.ts` (App Router) or `pages/api/*.ts` (Pages Router) files +become thin re-exports. + +**App Router:** + +```typescript +// src/app/api-routes/get-example-data.ts +import { getExamplesList } from '@/shared/db'; + +export const getExampleData = () => { + try { + const examplesList = getExamplesList(); + return Response.json({ examplesList }); + } catch { + return Response.json(null, { + status: 500, + statusText: 'Ouch, something went wrong', + }); + } +}; + +// src/app/api-routes/index.ts +export { getExampleData } from './get-example-data'; + +// app/api/example/route.ts +export { getExampleData as GET } from '@/app/api-routes'; +``` + +**Pages Router:** + +```typescript +// src/app/api-routes/get-example-data.ts +import type { NextApiRequest, NextApiResponse } from 'next'; + +const config = { api: { bodyParser: { sizeLimit: '1mb' } }, maxDuration: 5 }; +const handler = (req: NextApiRequest, res: NextApiResponse) => + res.status(200).json({ message: 'Hello from FSD' }); + +export const getExampleData = { config, handler } as const; + +// app/api/example.ts +import { getExampleData } from '@/app/api-routes'; +export const config = getExampleData.config; +export default getExampleData.handler; +``` + +FSD is primarily a frontend methodology. If `api-routes` grows to many +endpoints, consider moving the backend to a separate package in a monorepo. + +### Database access + +Place database queries in a `db` segment in `shared/` (`src/shared/db/`). +Co-locate caching and revalidation logic with the queries themselves. + +### Path aliases + +```json +// tsconfig.json +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/app/*": ["src/app/*"], + "@/pages/*": ["src/pages/*"], + "@/widgets/*": ["src/widgets/*"], + "@/features/*": ["src/features/*"], + "@/entities/*": ["src/entities/*"], + "@/shared/*": ["src/shared/*"] + } + } +} +``` + +Next.js reads `tsconfig.json` paths automatically. No `next.config.js` +alias configuration is needed. + +### Server Components and Public API splitting + +FSD layers work inside both Server and Client Components. However, the +standard single `index.ts` public API can cause problems in RSC environments +because re-exporting client and server code from the same entry point may +trigger bundler errors or unintended boundary crossings. + +Split the public API into multiple entry points per environment: + +```text +entities/user/ + model/ + user.ts + ui/ + UserAvatar.tsx ← 'use client', uses hooks + UserProfileCard.tsx ← Server Component, no hooks + api/ + user-queries.server.ts ← Server-only data fetching + index.ts ← Shared exports (types, pure functions) + index.client.ts ← Client component exports + index.server.ts ← Server component + server-only exports +``` + +```typescript +// entities/user/index.ts: shared (types, pure logic, no components) +export type { User } from "./model/user"; +export { formatUserName } from "./model/user"; + +// entities/user/index.client.ts: client components only +export { UserAvatar } from "./ui/UserAvatar"; + +// entities/user/index.server.ts: server components + server-only code +export { UserProfileCard } from "./ui/UserProfileCard"; +export { fetchUser } from "./api/user-queries.server"; +``` + +```typescript +// Consumers import from the appropriate entry point: + +// In a Server Component (pages/profile/ui/ProfilePage.tsx) +import { UserProfileCard } from "@/entities/user/index.server"; +import type { User } from "@/entities/user"; + +// In a Client Component (features/comment/ui/CommentAuthor.tsx) +import { UserAvatar } from "@/entities/user/index.client"; +``` + +**Rules for split public APIs:** + +1. **`index.ts`**: types, constants, and pure functions that work in both + environments. Default import path. +2. **`index.client.ts`**: components using `'use client'`, hooks, or + browser APIs. +3. **`index.server.ts`**: Server Components and server-only data fetching. +4. The `index.[env].ts` pattern generalises beyond RSC. Any meta-framework + with distinct runtime environments can use it (e.g., `index.edge.ts`). + Verified for Next.js App Router; Nuxt and Astro compatibility is under + review. +5. Steiger support for multiple entry points is available or coming in an + upcoming release. If Steiger flags these files, check for version + updates. + +**When NOT to split:** A slice with no client/server boundary concerns uses +a single `index.ts`. Split only when a slice actually has both client and +server exports. + + +## Nuxt 3 + +### Directory structure + +```text +my-nuxt-project/ + pages/ ← Nuxt file-based routing + index.vue ← Route entry, imports from FSD pages layer + profile.vue + src/ + app/ ← FSD app layer + providers/ + pages/ ← FSD pages layer + home/ + ui/HomePage.vue + index.ts + profile/ + ui/ProfilePage.vue + model/profile.ts + index.ts + shared/ ← FSD shared layer + ui/ + lib/ + api/ +``` + +### Wiring Nuxt routes to FSD pages + +```vue + + + +``` + +### Path aliases + +In addition to the standard `tsconfig.json` mapping, Nuxt requires explicit +runtime aliases in `nuxt.config.ts`: + +```typescript +// nuxt.config.ts +import { resolve } from "path"; + +export default defineNuxtConfig({ + alias: { + "@/app": resolve(__dirname, "src/app"), + "@/pages": resolve(__dirname, "src/pages"), + "@/widgets": resolve(__dirname, "src/widgets"), + "@/features": resolve(__dirname, "src/features"), + "@/entities": resolve(__dirname, "src/entities"), + "@/shared": resolve(__dirname, "src/shared"), + }, +}); +``` + + +## Vite + React + +### Directory structure + +```text +my-vite-project/ + src/ + app/ ← FSD app layer + providers/ + router.tsx + styles/ + main.tsx ← Entry point + pages/ + shared/ + index.html + vite.config.ts + tsconfig.json +``` + +### Path aliases + +Mirror the standard `tsconfig.json` mapping in `vite.config.ts` so the +Vite resolver agrees with TypeScript: + +```typescript +// vite.config.ts +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { resolve } from "path"; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + "@/app": resolve(__dirname, "src/app"), + "@/pages": resolve(__dirname, "src/pages"), + "@/widgets": resolve(__dirname, "src/widgets"), + "@/features": resolve(__dirname, "src/features"), + "@/entities": resolve(__dirname, "src/entities"), + "@/shared": resolve(__dirname, "src/shared"), + }, + }, +}); +``` + + +## Create React App (CRA) + +CRA is no longer actively maintained. **Migrate to Vite for new projects.** + +If you must stay on CRA, path aliases require ejecting or using `craco` to +override the webpack config: + +```javascript +// craco.config.js +const path = require("path"); + +module.exports = { + webpack: { + alias: { + "@/app": path.resolve(__dirname, "src/app"), + "@/pages": path.resolve(__dirname, "src/pages"), + "@/widgets": path.resolve(__dirname, "src/widgets"), + "@/features": path.resolve(__dirname, "src/features"), + "@/entities": path.resolve(__dirname, "src/entities"), + "@/shared": path.resolve(__dirname, "src/shared"), + }, + }, +}; +``` + + +## Astro + +Astro uses `src/pages/` for file-based routing, which collides with the FSD +`pages/` layer. Move the FSD pages layer to `src/_pages/` (with the +underscore prefix) and reserve `src/pages/` for Astro routes. + +### Directory structure + +```text +my-astro-project/ + src/ + pages/ ← Astro routing (thin entry points) + 404.astro + index.astro + _pages/ ← FSD pages layer + home/ + ui/HomePage.astro + index.ts + widgets/ + features/ + entities/ + shared/ +``` + +### Wiring Astro routes to FSD pages + +The Astro route file imports and renders the FSD page, nothing else: + +```astro +// src/pages/index.astro +import { HomePage } from '@/_pages/home'; + +``` + +### Path aliases (tsconfig.json) + +Astro projects use a single `@/*` alias instead of one alias per layer. This +is the convention the FSD Astro guide recommends: + +```json +{ + "extends": "astro/tsconfigs/strict", + "compilerOptions": { + "paths": { + "@/*": ["./src/*"] + } + } +} +``` + +Imports then reference the layer path directly: `@/_pages/home`, +`@/shared/ui`, `@/entities/user`. + +### Working with integrations + +Some Astro integrations (for example, Starlight) use content collections +that expect content in fixed folders such as `src/content/docs/`. If the +integration does not allow the path to be changed, leave it as-is. The +content folder lives alongside FSD layers without collision: + +```text +src/ + _pages/ ← FSD pages layer + content/ ← Integration content (Starlight, etc.) + docs/ + getting-started.md + shared/ ← FSD shared layer +``` + +Let the integration handle its own routing and rendering, while FSD layers +manage application-specific code. + + +## Key Reminders for All Frameworks + +1. **FSD lives in `src/`**: root-level `app/` and `pages/` belong to the + framework's routing, not FSD. +2. **Framework route files are thin wrappers**: they import and render FSD + page components. Business logic stays in FSD pages. +3. **Path aliases are required**: configure both the bundler and + `tsconfig.json`. +4. **Pages First still applies**: regardless of framework, start with code + in FSD `pages/` and extract only when needed. diff --git a/.agents/skills/feature-sliced-design/references/layer-structure.md b/.agents/skills/feature-sliced-design/references/layer-structure.md new file mode 100644 index 00000000..ad8e76ca --- /dev/null +++ b/.agents/skills/feature-sliced-design/references/layer-structure.md @@ -0,0 +1,490 @@ +# Layer Structure Reference + +Detailed folder structures, code examples, and naming conventions for each +FSD layer. Use this reference when creating, reviewing, or reorganizing +project structure. + +--- + +## App Layer + +App-wide initialization: providers, routing, global styles, entry point. +Organized by segments only, no slices. + +The methodology does not formally standardize App segment names. The +common convention list (`ui`, `api`, `model`, `lib`, `config`) applies to +all layers but is rarely a good fit here. In practice, projects use names +that describe purpose: `routes`, `store`, `styles`, `providers`, +`entrypoint`, etc. Choose names that match your stack (for example, +`providers` for React/Vue provider components that wrap Redux, +QueryClient, or theme contexts): + +```text +app/ + routes/ ← Route configuration (or router.tsx for single file) + store/ ← Global state store (Redux configureStore, Zustand root) + styles/ ← Global CSS, reset, theme variables + providers/ ← Provider components (Redux Provider, QueryClientProvider) + entrypoint.tsx ← Application entry point (main.tsx, index.tsx) +``` + +A smaller project may collapse some of these into single files: + +```text +app/ + router.tsx ← Route configuration + store.ts ← Store configuration + styles/ + global.css + providers.tsx ← All providers in one file + index.tsx ← Entry point +``` + +```typescript +// app/router.tsx +import { HomePage } from '@/pages/home'; +import { ProfilePage } from '@/pages/profile'; + +export const router = createBrowserRouter([ + { path: '/', element: }, + { path: '/profile/:id', element: }, +]); +``` + +**Belongs in app:** Global providers (Redux store, QueryClient, theme), +routing setup, global styles, error boundaries, analytics initialization. + +**Does not belong:** Feature-specific code, business logic, page-level UI. + +--- + +## Pages Layer + +Route-level composition. In v2.1, pages **own substantial logic**: they are +not thin wrappers. In early project stages, most code lives here. + +```text +pages/ + home/ + ui/ + HomePage.tsx + HeroSection.tsx + FeaturesGrid.tsx + model/ + home-data.ts ← Page-specific state + logic + api/ + fetch-home-data.ts ← Page-specific API calls + index.ts + profile/ + ui/ + ProfilePage.tsx + ProfileForm.tsx + ProfileStats.tsx + model/ + profile.ts ← Profile state + validation logic + api/ + update-profile.ts + fetch-profile.ts + index.ts +``` + +**Belongs in pages:** Page-specific UI, forms, validation, data fetching, +state management, business logic, API integrations. Even code that looks +reusable stays here if it is simpler to keep local. + +**Does not belong:** Code that is currently being reused across multiple +pages with stable boundaries (extract to a lower layer when reuse is +confirmed, not anticipated). + +### Page Layout Patterns + +A typical page composes widgets, features, and entities from lower layers, +plus its own local UI components: + +```typescript +// pages/product-detail/ui/ProductDetailPage.tsx +import { Header } from '@/widgets/header'; +import { AddToCart } from '@/features/add-to-cart'; +import { Product } from '@/entities/product'; + +export const ProductDetailPage = ({ productId }) => { + const product = useProductDetail(productId); // local hook in this page + + return ( + <> +
+ + + {/* local component */} + + ); +}; +``` + +For pages that only need shared + page-local code (no extracted layers): + +```typescript +// pages/about/ui/AboutPage.tsx +import { Card } from '@/shared/ui/Card'; +import { TeamSection } from './TeamSection'; // local to this page +import { MissionStatement } from './MissionStatement'; + +export const AboutPage = () => ( +
+ + +
+); +``` + +--- + +## Widgets Layer + +Composite UI blocks with their own logic, **reused across multiple pages**. +Add this layer only when UI blocks actually appear in 2+ pages and sharing +provides clear value. + +```text +widgets/ + header/ + ui/ + Header.tsx + Navigation.tsx + UserMenu.tsx + model/ + header.ts ← Widget state + api/ + fetch-notifications.ts + index.ts + sidebar/ + ui/ + Sidebar.tsx + model/ + sidebar.ts + index.ts +``` + +**Belongs in widgets:** Navigation bars, sidebars, dashboards, footers, +complex card layouts that combine data from multiple entities/features. + +**Does not belong:** Simple UI primitives (→ `shared/ui/`), single-use +page sections (→ keep in the page). + +--- + +## Features Layer + +Independent, reusable user interactions. **Create only when used in 2+ places.** + +```text +features/ + auth/ + ui/ + LoginForm.tsx + RegisterForm.tsx + model/ + auth.ts ← Auth state + logic + api/ + login.ts + register.ts + index.ts + add-to-cart/ + ui/ + AddToCartButton.tsx + model/ + cart.ts + index.ts + like-post/ + ui/ + LikeButton.tsx + model/ + like.ts + api/ + toggle-like.ts + index.ts +``` + +**Feature composition**: features consume entities and are composed in +higher layers: + +```typescript +// widgets/post-card/ui/PostCard.tsx +import { UserAvatar } from '@/entities/user'; +import { LikeButton } from '@/features/like-post'; +import { CommentButton } from '@/features/comment-create'; + +export const PostCard = ({ post }) => ( +
+ +

{post.title}

+

{post.content}

+
+ + +
+
+); +``` + +--- + +## Entities Layer + +Reusable business domain models. **Create only when used in 2+ places. Starting +without this layer is completely valid.** + +```text +// Minimal entity: model only (most common form) +entities/user/ + model/ + user.ts ← Types + domain logic + index.ts + +// Entity with UI (use with caution) +// ⚠️ Adding UI to entities increases cross-import risk. +// Other entities may want to import this UI, leading to @x dependencies. +// Entity UI should only be imported from higher layers (features, widgets, +// pages), never from other entities. +entities/product/ + model/ + product.ts + ui/ + ProductCard.tsx + index.ts +``` + +--- + +## Shared Layer Structure + +Infrastructure with no business logic. Organized by segments only (no slices). +Segments may import from each other. + +```text +shared/ + ui/ ← UI kit: Button, Input, Modal, Card + lib/ ← Utilities: formatDate, debounce, classnames + api/ ← API client, route constants, CRUD helpers, base types + auth/ ← Auth tokens, login utilities, session management + config/ ← Environment variables, app settings + assets/ ← Branding assets shared across the app (use sparingly) +``` + +```typescript +// shared/ui/Button/Button.tsx +export const Button = ({ children, onClick, variant = 'primary' }) => ( + +); + +// shared/ui/Button/index.ts +export { Button } from './Button'; +export type { ButtonProps } from './Button'; +``` + +Shared **may** contain application-aware code (route constants, API endpoints, +branding assets, common types). It must **never** contain business logic, +feature-specific code, or entity-specific code. + +For asset placement specifically (images, icons, fonts, PDFs), see +`references/asset-handling.md`. + +--- + +## Segments + +A segment groups related code within a slice (or within App/Shared). The +standard segments cover the most common technical purposes: + +- **`ui`**: UI display (components, date formatters, styles). +- **`api`**: backend interactions (request functions, data types, mappers). +- **`model`**: data model (schemas, interfaces, stores, business logic). +- **`lib`**: library code that other modules in this slice need. +- **`config`**: configuration files and feature flags. + +Custom segments are allowed when needed (for example, `routes` and `i18n` +in the Shared layer, or `auth` for token storage when split out from +`shared/api`). + +### Group by what it is *for*, not by what it *is* + +Segment names describe **purpose**, not the kind of code they hold. This +is the desegmentation principle: + +```text +// ❌ BAD: grouping by technical kind (what the code is) +shared/ + components/ ← What kind of components? + hooks/ ← Which feature do they serve? + types/ ← Which domain do they describe? + utils/ ← Utility for what? + helpers/ ← Same problem + actions/ ← Redux actions for what? + +// ✅ GOOD: grouping by purpose (what the code is for) +shared/ + ui/ ← For displaying UI + api/ ← For talking to the backend + lib/ ← For library code that supports the slice + config/ ← For configuration +``` + +A segment named `types/` cannot answer "types for what?" without inspecting +the contents. A segment named `model/` says: this is the data model. +Inside `model/`, files are named by domain (`user.ts`, `order.ts`), not by +technical role. + +This rule applies everywhere: in `shared/`, in slices, and when designing +new custom segments. + +## Naming Conventions + +### Domain-based file naming + +Within a segment, name files after the business domain, not the technical +role: + +```text +// ❌ Technical-role naming: mixes domains +model/types.ts ← Which types? User? Order? +model/utils.ts +api/endpoints.ts +model/selectors.ts + +// ✅ Domain-based naming: each file owns one domain +model/user.ts ← User types + logic + store +model/order.ts ← Order types + logic + store +api/fetch-profile.ts ← Clear what this API does +model/todo.ts ← Redux slice + selectors + thunks +``` + +### Single-concern segments + +If a segment contains only one domain concern, the filename may match the +slice name: + +```text +features/auth/ + model/ + auth.ts ← Single concern, matches slice name +``` + +### Index files as public API + +Every slice must have an `index.ts` that re-exports its public interface: + +```typescript +// entities/user/index.ts +export { UserAvatar } from "./ui/UserAvatar"; +export { useUser, type User } from "./model/user"; +``` + +--- + +## Slice Groups + +A **slice group** is a folder that contains related slices on the same +layer, used purely to make the structure easier to navigate as the number +of slices grows. A slice group is **not** a slice itself: it has no +segments (`model/`, `ui/`, `api/`), no public API (`index.ts`), and no +shared code. Slice isolation rules apply unchanged inside a group: sibling +slices in the same group cannot import from each other. + +Slice groups are optional. Use them only when the layer has grown large +enough that a flat structure becomes hard to scan and there is an obvious +grouping criterion. + +### When to use + +- Several slices share the same business context and are scattered across + the layer. +- The slice names clearly suggest they belong to the same topic. +- The layer has grown to the point where it is hard to scan at a glance. + +### When NOT to use + +- Names alone are enough for quick navigation. +- There is no natural grouping criterion. +- Only two or three slices would end up in the group. + +### Example: grouping payment-related entities + +```text +entities/ + payment/ ← Slice group (no public API) + invoice/ ← Slice + model/ + ui/ + index.ts + receipt/ ← Slice (model/, ui/, index.ts) + transaction/ ← Slice (model/, ui/, index.ts) + user/ ← Slice (not in any group) + product/ ← Slice +``` + +Imports go through the full path: + +```typescript +import { Invoice } from "@/entities/payment/invoice"; +import { Receipt } from "@/entities/payment/receipt"; +``` + +The same pattern applies to the Pages layer. For example, grouping +`pages/order/{list,detail,create}` when there are multiple pages on the same +topic such as list, detail, create, and edit. This is one possible example +and does not represent the default structure for the Pages layer. + +### Features: use with caution + +Slice groups can be applied to Features, but features often span multiple +entities and lack a natural grouping criterion. A group like +`features/cart/` tends to attract everything cart-related (DTOs, mappers, +helpers) until it stops being a navigation aid and starts acting as the +home for the entire cart domain, which weakens the principle that +features are split by use case. Before grouping features, check that the +group contains only feature slices and that two or three slices is not the +entire content. + +### Anti-patterns + +- **Do not put `index.ts` on the group folder.** That promotes the group + to a slice and breaks the layer's contract. +- **Do not put shared `utils.ts`, `constants.ts`, or `types.ts` files + inside the group.** A slice group has no shared code. Extract reusable + code to `shared/` instead. If the layer is `entities` and the shared + logic is genuinely domain logic, consider whether the boundaries are + too granular and the slices should be merged into one isolated entity + (see `references/excessive-entities.md`). The `@x` notation does not + apply to slice groups. It is a cross-import surface between entity + slices, not a sharing mechanism for siblings within a group. +- **Do not relax slice isolation inside the group.** If two slices in the + same group need to share code, extract it one layer down rather than + adding a `_common/` file. + +--- + +## Path Aliases + +Configure path aliases so imports follow the `@/layer/slice` pattern: + +```json +// tsconfig.json +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/app/*": ["src/app/*"], + "@/pages/*": ["src/pages/*"], + "@/widgets/*": ["src/widgets/*"], + "@/features/*": ["src/features/*"], + "@/entities/*": ["src/entities/*"], + "@/shared/*": ["src/shared/*"] + } + } +} +``` + +For framework-specific alias configuration (Vite, Next.js, Nuxt, Astro), +see `references/framework-integration.md`. diff --git a/.agents/skills/feature-sliced-design/references/migration-guide.md b/.agents/skills/feature-sliced-design/references/migration-guide.md new file mode 100644 index 00000000..ebafffcd --- /dev/null +++ b/.agents/skills/feature-sliced-design/references/migration-guide.md @@ -0,0 +1,293 @@ +# Migration Guide + +How to migrate to FSD v2.1 from either FSD v2.0 or a custom (non-FSD) +architecture. This guide reflects the official `from-custom` step order: +**pages first**, then everything else. + +## Part 1: FSD v2.0 → v2.1 (non-breaking) + +The v2.1 update emphasizes **"pages first"**: most logic stays in pages, +reusable foundation in Shared. If reuse is needed across several pages, +move it to a layer below. The migration is non-breaking and simplifies +the codebase by relocating single-use code back to where it is consumed. + +Another addition in v2.1 is the standardization of cross-imports between +entities with the `@x` notation. See `references/cross-import-patterns.md`. + +### Step 1. Audit existing slices + +Use Steiger to detect slices that are used in only one place: + +```bash +npm install -D @feature-sliced/steiger +npx steiger src +``` + +Look for these rules: + +- **`insignificant-slice`**: an entity or feature used by only one page. + This rule will suggest merging that entity or feature into the page + entirely. +- **`excessive-slicing`**: too many slices in a single layer. + +For each flagged slice, decide: + +- Reused in 2+ places → keep in features/entities. +- Used only in one page → mark for migration back into that page. + +### Step 2. Move single-use code back to its consumer + +Take single-use features and entities and inline them into the consuming +page (or widget if that is the single consumer): + +```text +// Before (v2.0): feature used by only one page +features/user-profile-form/ + ui/ProfileForm.tsx + model/profile-form.ts + api/update-profile.ts + index.ts +pages/profile/ + ui/ProfilePage.tsx ← Thin wrapper, just composes + +// After (v2.1): code lives in the page that owns it +pages/profile/ + ui/{ProfilePage,ProfileForm}.tsx + model/profile.ts ← Merged form logic + api/update-profile.ts + index.ts +``` + +For each moved slice: + +1. Copy all files into the consuming page. +2. Update the page's `index.ts` to export what is needed externally. +3. Update all imports across the codebase to point to the new location. +4. Delete the now-empty feature/entity directory. +5. Run tests. + +### Step 3. Keep genuinely reused code in place + +Code confirmed to be used in 2+ places stays in features/entities. Do not +move it. The point of v2.1 is reducing premature extraction, not removing +reuse. + +### Step 4. Deprecate the processes layer + +The `processes` layer is deprecated. Migrate its code: + +- **Multi-page workflows** (checkout, onboarding wizard): move + orchestration logic to the page that initiates the workflow. If multiple + pages share workflow state, create a feature for it. +- **Background processes** (polling, sync): move to `app/` if global, or + to the relevant page/feature if scoped. + +```text +// Before +processes/ + checkout/model/checkout-flow.ts + sync/model/background-sync.ts + +// After +features/checkout/model/checkout-flow.ts ← Used in 2+ pages +app/sync/background-sync.ts ← Global concern +``` + +### Post-migration verification + +1. Run `npx steiger src`. All `insignificant-slice` warnings should be gone. +2. Verify import directions. No upward or same-layer cross-imports. +3. Check that no empty layer directories remain. +4. Update documentation to reflect the new structure. + +## Part 2: Custom architecture → FSD + +This part follows the official `from-custom` migration order. The core +philosophy is **pages first**: start by dividing the code by pages, then +work outward. + +### Before you start + +The most important question to ask the team is: *do you really need it?* +Some projects are perfectly fine without FSD. Reasons to consider the +switch: + +1. New team members struggle to reach a productive level. +2. Modifications to one part of the code **often** break unrelated parts. +3. Adding new functionality is difficult due to the volume of context to + hold in mind. + +**Avoid switching to FSD against the will of teammates**, even as a lead. +Convince the team that the benefits outweigh migration and learning costs. +Explain the migration plan to management; architectural changes are not +immediately observable to them. + +If the decision is made, set up a path alias for `src/` first. This guide +uses `@` as an alias for `./src`. + +### Step 1. Divide the code by pages + +If `pages/` already exists, skip this step. Otherwise, create `pages/` and +move as much component code as possible from `routes/` (or equivalent) into +it. Aim for tiny route files that just re-export from page slices. + +```text +// Route file (thin) +src/routes/products.[id].js + export { ProductPage as default } from "@/pages/product" + +// Page slice +src/pages/product/ + ui/ProductPage.jsx + index.js ← export { ProductPage } from "./ProductPage.jsx" +``` + +Pages may reference each other for now. Tackle that later. Focus on +establishing a prominent division by pages. + +### Step 2. Separate everything else from pages + +Create `src/shared/` and move everything that does **not** import from +`pages/` or `routes/` there. Create `src/app/` and move everything that +**does** import the pages or routes there, including the routes themselves. + +The Shared layer has no slices, so segments may import from each other. + +```text +src/ + app/ + routes/ + products.jsx + products.[id].jsx + App.jsx + index.js + pages/ + product/ + ui/ProductPage.jsx + index.js + catalog/ + shared/ + actions/, api/, components/, containers/, constants/, + i18n/, modules/, helpers/, utils/, reducers/, selectors/, styles/ +``` + +### Step 3. Tackle cross-imports between pages + +Find all cases where one page imports from another. Resolve each in one of +two ways: + +1. **Copy-paste** the imported code into the depending page to remove the + dependency. +2. **Move to a Shared segment**: + - UI kit code → `shared/ui/` + - configuration constants → `shared/config/` + - backend interaction → `shared/api/` + +Copy-pasting is **not architecturally wrong**. Sometimes it is more correct +to duplicate than to abstract into a new reusable module, because the +shared parts of pages can drift apart over time. Still, the DRY principle +holds for business logic: avoid copy-pasting code that must stay in sync +across multiple places. + +### Step 4. Unpack the Shared layer + +The Shared layer can become bloated after Step 2. Find every object used in +only one page and move it to that page's slice. **This applies to actions, +reducers, and selectors too.** There is no benefit in grouping all actions +together, but there is benefit in colocating relevant actions close to +their usage. + +```text +src/ + pages/ + product/ + actions/, reducers/, selectors/, ui/ ← moved from shared + index.js + catalog/ + shared/ ← only objects that are reused + actions/, api/, components/, ... +``` + +### Step 5. Organize code by technical purpose (segments) + +In FSD, division by technical purpose is done with **segments**. The common +ones are: + +- **`ui`**: everything related to UI display (components, date formatters, + styles). +- **`api`**: backend interactions (request functions, data types, mappers). +- **`model`**: the data model (schemas, interfaces, stores, business + logic). +- **`lib`**: library code that other modules in the slice need. +- **`config`**: configuration files and feature flags. + +Custom segments are allowed when needed. **Do not create segments that +group code by what it is**, like `components`, `actions`, `types`, or +`utils`. Group code by what it is **for**, not by what it is. This is the +desegmentation principle. + +Reorganize each page to separate code by segments: + +- The existing page UI files become the `ui` segment. +- Actions, reducers, and selectors become the `model` segment. +- Thunks and mutations become the `api` segment. + +Reorganize the Shared layer too: + +- `components/`, `containers/` → most of it becomes `shared/ui/`. +- `helpers/`, `utils/` → group by function (dates, type conversions, etc.) + and move groups to `shared/lib/`. +- `constants/` → group by function and move to `shared/config/`. + +## Optional steps + +### Step 6. Form entities/features from Redux slices used on several pages + +Reused Redux slices typically describe business concepts (products, users) +or user actions (comments, likes): + +- Business entities → **Entities layer**, one entity per folder. +- User actions → **Features layer**. + +Entities and features are meant to be independent. If your business domain +contains inherent connections between entities (a song belongs to an +artist), see the +[business entities cross-references guide](https://fsd.how/docs/guides/examples/types#business-entities-and-their-cross-references). + +API functions related to these slices can stay in `shared/api`. + +### Step 7. Refactor your modules + +The `modules/` folder typically holds business logic, similar in nature to +the Features layer. Some modules describe large UI chunks (an app header) +which belong in the Widgets layer. + +### Step 8. Form a clean UI foundation in `shared/ui` + +`shared/ui` should contain UI elements with no encoded business logic. +Refactor components from `components/` and `containers/` to extract their +business logic to higher layers. If business logic is not used in many +places, copy-pasting back to consumers is an acceptable choice. + +## Common pitfalls during migration + +1. **Extracting too early.** Wait for real reuse, not anticipated reuse. + The v2.1 philosophy is "pages first, extract later". +2. **Creating empty layers.** Do not create `features/`, `entities/`, or + `widgets/` directories until there is content for them. +3. **Refactoring while migrating.** Separate relocation from refactoring. + Move files first, improve them in separate commits. +4. **Ignoring import direction.** Enforce import rules from day one with + ESLint or Steiger. +5. **Big-bang migration.** Migrate page by page, verifying each step. A + hybrid structure (partly FSD, partly legacy) is acceptable during + transition. +6. **Grouping by technical role.** `components/`, `actions/`, `utils/` as + segment names defeat the purpose of FSD. Group by what code is for. + +## Migrating from FSD v1 to v2 + +This guide does not cover v1 → v2. See the official +[v1 to v2 migration guide](https://fsd.how/docs/guides/migration/from-v1). +The v1 → v2 transition introduced the entities and processes layers +(processes was later deprecated in v2.1). diff --git a/.agents/skills/feature-sliced-design/references/practical-examples.md b/.agents/skills/feature-sliced-design/references/practical-examples.md new file mode 100644 index 00000000..979ff94f --- /dev/null +++ b/.agents/skills/feature-sliced-design/references/practical-examples.md @@ -0,0 +1,533 @@ +# Practical Examples + +Concrete code patterns for common scenarios within FSD structure. Covers +authentication, type definitions, API request handling, and state management +integration (Redux, TanStack Query / React Query). + +## Authentication + +Auth is one of the most common sources of confusion in FSD. The key question +is: what goes in `shared/`, what goes in `features/` or `pages/`? + +### Auth data: `shared/auth/` or `shared/api/` + +Tokens, session state, and login utilities are **infrastructure**, not +business logic. Keep them in shared: + +```typescript +// shared/auth/token.ts +const TOKEN_KEY = "auth_token"; +export const getToken = () => localStorage.getItem(TOKEN_KEY); +export const setToken = (t: string) => localStorage.setItem(TOKEN_KEY, t); +export const clearToken = () => localStorage.removeItem(TOKEN_KEY); + +// shared/auth/session.ts +export interface Session { userId: string; email: string; role: "admin" | "user" } +// useSession depends on the auth provider (React Context, Zustand, etc.) +export const useSession = (): Session | null => { /* ... */ }; +``` + +The `shared/auth/index.ts` re-exports from these files following the +standard public API pattern. + +### Auth UI: pages (single use) or features (multi-use) + +Place the login form in the slice that consumes it. Single-use (only on the +login page) goes in `pages/login/`; multi-use (dedicated page + modal login) +goes in `features/auth/`: + +```text +pages/login/ ← Single-use + ui/{LoginPage,LoginForm}.tsx + model/login.ts ← Form state, validation + api/login.ts ← POST /auth/login + index.ts + +features/auth/ ← Multi-use + ui/{LoginForm,RegisterForm}.tsx + model/auth.ts + api/{login,register}.ts + index.ts +``` + +### When to use shared/auth vs a user entity + +The official Auth guide presents two valid storage locations: **In Shared** +(`shared/auth` or `shared/api`) and **In Entities** (a `user` entity). +Pages and widgets are discouraged. + +`shared/auth` is the simpler default. Choose it when the project has no +entities layer yet, or when auth state is just a token plus minimal user info. + +A `user` entity is the right call when the project already has an +entities layer **and** auth and profile data are tightly coupled (profile +reused for non-auth purposes like avatars in comments). + +```text +// Path A: shared/auth (simpler default) +shared/auth/session.ts ← userId, email, role, token + +// Path B: user entity (entities layer exists, profile reuse is real) +entities/user/ + model/ + current-user.ts ← Current authenticated user + token + user.ts ← Generic user type + api/get-current-user.ts + index.ts +``` + +For the entity approach, the API client in `shared/api` cannot import from +`entities/`. The official guide describes three solutions: pass the token +manually, expose it through a context with the key kept in `shared/api`, +or inject the token into the API client when the entity store updates. + +A `user` entity created **only** to wrap a login response is premature. +See `references/excessive-entities.md` for the full decision matrix. + +## Type Definitions + +### Where to define types + +The location of type definitions follows the same rules as any other code: + +| Type scope | Location | +| --- | --- | +| API response/request shapes shared across the app | Domain-named files in `shared/api/` (e.g., `shared/api/product.ts`) | +| Types for a specific entity's domain model | `entities//model/.ts` | +| Types used only within one page | `pages//model/.ts` | +| Types used only within one feature | `features//model/.ts` | +| Generic utility types (e.g., `Nullable`) | Domain-named files in `shared/lib/` (e.g., `shared/lib/nullable.ts`) | + +Per Rule 4-4 (domain-based file naming), avoid grouping all types in +`types.ts` or `utils.ts`. A file named `types.ts` cannot answer "types +for what?" without inspection; a file named `product.ts` can. + +### Example: API types in shared + +```typescript +// shared/api/product.ts: raw API response shapes +export interface ProductDTO { + id: string; + name: string; + price: number; + category: string; + createdAt: string; +} +``` + +### Example: Domain types in entities + +```typescript +// entities/product/model/product.ts: domain model layered on top +import type { ProductDTO } from "@/shared/api/product"; + +export interface Product extends ProductDTO { + formattedPrice: string; + isOnSale: boolean; +} + +export const fromDTO = (dto: ProductDTO): Product => ({ + ...dto, + formattedPrice: `$${dto.price.toFixed(2)}`, + isOnSale: dto.price < 10, +}); +``` + +**Key principle:** Raw API shapes go in `shared/api/`. Domain models with +business logic go in `entities/`. If you only need the raw shape, do not +create an entity just for types. + +## API Request Handling + +### Basic pattern: API calls in the consuming slice + +```typescript +// pages/product-detail/api/fetch-product.ts +import { apiClient } from "@/shared/api/client"; +import type { ProductDTO } from "@/shared/api/product"; + +export const fetchProduct = (id: string): Promise => + apiClient.get(`/products/${id}`).then((r) => r.data); +``` + +### Shared API client setup + +```typescript +// shared/api/client.ts +import axios from "axios"; +import { getToken } from "@/shared/auth/token"; + +export const apiClient = axios.create({ baseURL: import.meta.env.VITE_API_URL }); + +apiClient.interceptors.request.use((config) => { + const token = getToken(); + if (token) config.headers.Authorization = `Bearer ${token}`; + return config; +}); +``` + +### CRUD helpers in shared + +```typescript +// shared/api/create-crud-api.ts +import { apiClient } from "./client"; + +export const createCrudApi = (resource: string) => ({ + getAll: () => apiClient.get(`/${resource}`).then((r) => r.data), + getById: (id: string) => apiClient.get(`/${resource}/${id}`).then((r) => r.data), + create: (data: Partial) => apiClient.post(`/${resource}`, data).then((r) => r.data), + update: (id: string, data: Partial) => apiClient.put(`/${resource}/${id}`, data).then((r) => r.data), + remove: (id: string) => apiClient.delete(`/${resource}/${id}`), +}); + +// Usage: export const productsApi = createCrudApi("products"); +``` + +### Request placement rule + +Place each request function in the slice that owns the use case: + +- **Page-specific data fetching** (e.g., dashboard stats only used on the + dashboard) → `pages//api/` +- **Feature-specific actions** (e.g., `toggleLike`) → `features//api/` +- **Reusable domain queries** (e.g., `getUserById`) → `entities//api/` +- **CRUD primitives** for a generic resource → `shared/api/create-crud-api.ts` + +Do not put domain-specific request functions in `shared/api/`. Shared is +infrastructure; the moment a function knows about a specific resource and +its domain rules, it belongs in `entities/` or higher. + +## State Management: Redux + +### Where a Redux slice belongs + +The `from-custom` migration guide draws a clean line: **business +entities** (the things your app works with, like `todo`, `product`, `user`) +go in the Entities layer; **user actions** (`add-todo`, `toggle-todo`, +`like-post`) go in Features. + +In v2.1, also remember the pages-first rule: if the slice is used by a +single page, keep it in that page's `model/` segment until reuse appears. + +### Business-entity slice in entities + +```typescript +// entities/todo/model/todo.ts +import { createSlice, createAsyncThunk } from "@reduxjs/toolkit"; +import { apiClient } from "@/shared/api/client"; + +interface Todo { id: string; title: string; completed: boolean } +interface TodoState { items: Todo[]; loading: boolean } + +export const fetchTodos = createAsyncThunk("todos/fetch", async () => + (await apiClient.get("/todos")).data, +); + +const todoSlice = createSlice({ + name: "todos", + initialState: { items: [], loading: false } as TodoState, + reducers: { + setCompleted: (state, { payload }: { payload: { id: string; completed: boolean } }) => { + const todo = state.items.find((t) => t.id === payload.id); + if (todo) todo.completed = payload.completed; + }, + }, + extraReducers: (builder) => { + builder + .addCase(fetchTodos.pending, (state) => { state.loading = true; }) + .addCase(fetchTodos.fulfilled, (state, action) => { + state.items = action.payload; + state.loading = false; + }); + }, +}); + +export const { setCompleted } = todoSlice.actions; +export const selectTodos = (state: RootState) => state.todos.items; +export const todoReducer = todoSlice.reducer; +``` + +The slice's public API re-exports what consumers need: + +```typescript +// entities/todo/index.ts +export { todoReducer, selectTodos, setCompleted, fetchTodos } from "./model/todo"; +``` + +**Key:** The entire Redux slice (reducer + selectors + thunks) lives in a +single domain-named file, not split across `reducers.ts`, `selectors.ts`, +`thunks.ts`. That technical-role split reduces cohesion and is an +anti-pattern in FSD. + +### User-action slice in features + +A user action that orchestrates the entity exposes a hook through its +public API and consumes the entity's reducer: + +```typescript +// features/toggle-todo/model/use-toggle-todo.ts +import { useDispatch } from "react-redux"; +import { setCompleted } from "@/entities/todo"; + +export const useToggleTodo = () => { + const dispatch = useDispatch(); + return (id: string, current: boolean) => + dispatch(setCompleted({ id, completed: !current })); +}; +``` + +### Registering slices in app + +```typescript +// app/providers/store.ts +import { configureStore } from "@reduxjs/toolkit"; +import { todoReducer } from "@/entities/todo"; +import { userReducer } from "@/entities/user"; + +export const store = configureStore({ + reducer: { + todos: todoReducer, + user: userReducer, + }, +}); + +export type RootState = ReturnType; +``` + +The store imports each slice's reducer through its public API +(`index.ts`), never reaching into `model/` directly (Rule 4-2). Do not +let individual slices create their own stores. + +## State Management: TanStack Query (React Query) + +Guidance applies to `@tanstack/react-query` v5 (formerly React Query). The +package name is `@tanstack/react-query`. + +### Where to store query keys + +Three placements are valid. Choose based on project size and whether the +project already has an Entities layer. + +**Option 1: Flat in `shared/api/queries/`** (small projects, few endpoints): + +```text +shared/api/ + queries/ + example.ts + another-example.ts + index.ts ← export { exampleQueries } from './queries/example'; +``` + +**Option 2: Per controller in `shared/api//`** (many endpoints): + +```text +shared/api/example/ + index.ts ← export { exampleQueries } from './example.query'; + example.query.ts ← Query factory: keys + functions + get-example.ts + create-example.ts + update-example.ts + delete-example.ts +``` + +**Option 3: Per entity in `entities//api/`** when each request +corresponds to a single entity, and the project already has an Entities +layer. When entities reference each other, see +`references/cross-import-patterns.md` for `@x` notation as a last resort. + +### Where to store mutations + +Do not mix mutations with queries. Two patterns are accepted: + +1. **A mutation hook in the `api/` segment near the place of use.** Use + `setQueryData` for cache updates: + + ```typescript + // src/pages/example/api/use-update-example.ts + export const useUpdateExample = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, newTitle }) => apiClient.patch(`/posts/${id}`, { title: newTitle }).then((r) => r.data), + onSuccess: (newPost, { id }) => queryClient.setQueryData(POST_QUERIES.detail({ id }).queryKey, newPost), + }); + }; + ``` + +2. **A `mutationFn` defined in `shared/` or `entities/`** and called from + `useMutation` in the component. + +### Query factory pattern + +A query factory is an object whose values return query keys. Each key is +wrapped in `queryOptions`, a built-in helper from `@tanstack/react-query` v5 +that lets you share `queryKey` and `queryFn` between `useQuery`, +`useSuspenseQuery`, `prefetchQuery`, `setQueryData`, and similar APIs +without rewriting them: + +```typescript +// src/shared/api/post/post.queries.ts +import { queryOptions } from "@tanstack/react-query"; +import { getPosts, getDetailPost, type DetailPostQuery } from "./get-posts"; + +export const POST_QUERIES = { + all: () => ["posts"], + lists: () => [...POST_QUERIES.all(), "list"], + list: (page: number, limit: number) => queryOptions({ + queryKey: [...POST_QUERIES.lists(), page, limit], + queryFn: () => getPosts(page, limit), + placeholderData: (prev) => prev, + }), + detail: (query?: DetailPostQuery) => queryOptions({ + queryKey: [...POST_QUERIES.all(), "detail", query?.id], + queryFn: () => getDetailPost({ id: query?.id }), + }), +}; +``` + +Consume with `useQuery(POST_QUERIES.detail({ id }))`. For pagination, +`placeholderData: prev => prev` prevents UI flicker when navigating pages. + +**Benefits of a query factory:** all API requests for a domain live in one +place (readability), every key and query function is reachable through the +same object (convenient access), and refetching is a one-line call +(`queryClient.invalidateQueries({ queryKey: POST_QUERIES.all() })`) without +hunting down keys across the codebase. + +### Infinite scroll + +Use `infiniteQueryOptions` with `initialPageParam` and `getNextPageParam`. +Add the infinite key to the same factory shown above: + +```typescript +import { infiniteQueryOptions } from "@tanstack/react-query"; + +// Inside POST_QUERIES: +infinite: (limit: number) => infiniteQueryOptions({ + queryKey: [...POST_QUERIES.lists(), "infinite", limit], + queryFn: ({ pageParam }) => getPosts(pageParam, limit), + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.skip + lastPage.limit < lastPage.total ? lastPage.skip / lastPage.limit + 1 : undefined, +}), +``` + +Consume with `useInfiniteQuery` and flatten via `data?.pages.flatMap(...)`. + +### Suspense mode + +`queryOptions` and `useSuspenseQuery` are compatible, and the factory does +not change. Components use `useSuspenseQuery` instead of `useQuery` and skip +`isLoading` entirely. Wrap interested subtrees with an `ErrorBoundary` + +`Suspense` provider in the App layer: + +```tsx +// src/app/providers/suspense-provider.tsx +import { Suspense } from "react"; +import { ErrorBoundary } from "react-error-boundary"; + +export const SuspenseProvider = ({ children }) => ( + Something went wrong
}> + Loading...}>{children} + +); +``` + +### Reading mutation state with useMutationState + +`useMutationState` lets any component read the state of a mutation without +passing props, useful for global save indicators. Store mutation keys next +to the query factory: + +```typescript +// src/shared/api/post/post.queries.ts +export const POST_MUTATIONS = { + updateTitle: () => ["post", "update-title"], + create: () => ["post", "create"], +}; +``` + +Tag the mutation with `mutationKey`, then read its state from any component: + +```tsx +// src/features/update-post/api/use-update-post-title.ts +export const useUpdatePostTitle = () => + useMutation({ + mutationKey: POST_MUTATIONS.updateTitle(), + mutationFn: ({ id, newTitle }) => apiClient.patch(`/posts/${id}`, { title: newTitle }), + }); + +// src/widgets/save-indicator/ui/save-indicator.tsx +import { useMutationState } from "@tanstack/react-query"; +import { POST_MUTATIONS } from "@/shared/api/post"; + +export const SaveIndicator = () => { + const isPending = useMutationState({ + filters: { mutationKey: POST_MUTATIONS.updateTitle(), status: "pending" }, + select: (m) => m.state.status, + }).length > 0; + return isPending && Saving...; +}; +``` + +### QueryProvider in the app layer + +```tsx +// src/app/providers/query-provider.tsx +import { QueryClient, QueryClientProvider, MutationCache, QueryCache } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { toast } from "sonner"; + +const queryClient = new QueryClient({ + queryCache: new QueryCache({ onError: (e) => toast.error(e.message) }), + mutationCache: new MutationCache({ onError: (e) => toast.error(e.message) }), + defaultOptions: { queries: { staleTime: 5 * 60 * 1000, gcTime: 5 * 60 * 1000 } }, +}); + +export const QueryProvider = ({ children }) => ( + + {children} + + +); +``` + +`QueryCache.onError` and `MutationCache.onError` give one place to wire up +global toast notifications instead of repeating error handling on every hook. + +### Code generation + +Tools that generate clients from an OpenAPI/Swagger spec are less flexible +than hand-written factories. If your spec is clean and you adopt a generator, +place the generated code in `@/shared/api/`. + +### Custom API client + +Standardize base URL, headers, and JSON handling in a single class in +`shared/api/`: + +```typescript +// src/shared/api/api-client.ts +export class ApiClient { + #baseUrl: string; + constructor(url: string) { this.#baseUrl = url; } + + async #handle(response: Response): Promise { + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json(); + } + + get = (path: string) => fetch(`${this.#baseUrl}${path}`).then((r) => this.#handle(r)); + // post, put, delete follow the same pattern with method/headers/body. +} + +export const apiClient = new ApiClient(API_URL); +``` + +**Key principle:** Place query and mutation hooks in the slice that owns the +domain. Page-specific queries stay in the page. Shared queries go in +`shared/api/` or `entities//api/` depending on whether the project has +an Entities layer. + +## See also + +- [Sample project on GitHub](https://github.com/ruslan4432013/fsd-react-query-example) +- [Query options API (tkdodo blog)](https://tkdodo.eu/blog/the-query-options-api) diff --git a/.agents/skills/frontend-design/SKILL.md b/.agents/skills/frontend-design/SKILL.md index f709fde7..5be498e2 100644 --- a/.agents/skills/frontend-design/SKILL.md +++ b/.agents/skills/frontend-design/SKILL.md @@ -11,7 +11,6 @@ The user provides frontend requirements: a component, page, application, or inte ## Design Thinking Before coding, understand the context and commit to a BOLD aesthetic direction: - - **Purpose**: What problem does this interface solve? Who uses it? - **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction. - **Constraints**: Technical requirements (framework, performance, accessibility). @@ -20,7 +19,6 @@ Before coding, understand the context and commit to a BOLD aesthetic direction: **CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity. Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is: - - Production-grade and functional - Visually striking and memorable - Cohesive with a clear aesthetic point-of-view @@ -29,7 +27,6 @@ Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is: ## Frontend Aesthetics Guidelines Focus on: - - **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font. - **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes. - **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise. diff --git a/.agents/skills/paddle-webhooks/SKILL.md b/.agents/skills/paddle-webhooks/SKILL.md index 5521d5af..7e640816 100644 --- a/.agents/skills/paddle-webhooks/SKILL.md +++ b/.agents/skills/paddle-webhooks/SKILL.md @@ -25,62 +25,61 @@ metadata: ### Express Webhook Handler ```javascript -const express = require("express"); -const crypto = require("crypto"); +const express = require('express'); +const crypto = require('crypto'); const app = express(); // CRITICAL: Use express.raw() for webhook endpoint - Paddle needs raw body -app.post( - "/webhooks/paddle", - express.raw({ type: "application/json" }), +app.post('/webhooks/paddle', + express.raw({ type: 'application/json' }), async (req, res) => { - const signature = req.headers["paddle-signature"]; - + const signature = req.headers['paddle-signature']; + if (!signature) { - return res.status(400).send("Missing Paddle-Signature header"); + return res.status(400).send('Missing Paddle-Signature header'); } // Verify signature const isValid = verifyPaddleSignature( req.body.toString(), signature, - process.env.PADDLE_WEBHOOK_SECRET, // From Paddle dashboard + process.env.PADDLE_WEBHOOK_SECRET // From Paddle dashboard ); if (!isValid) { - console.error("Paddle signature verification failed"); - return res.status(400).send("Invalid signature"); + console.error('Paddle signature verification failed'); + return res.status(400).send('Invalid signature'); } const event = JSON.parse(req.body.toString()); // Handle the event switch (event.event_type) { - case "subscription.created": - console.log("Subscription created:", event.data.id); + case 'subscription.created': + console.log('Subscription created:', event.data.id); break; - case "subscription.canceled": - console.log("Subscription canceled:", event.data.id); + case 'subscription.canceled': + console.log('Subscription canceled:', event.data.id); break; - case "transaction.completed": - console.log("Transaction completed:", event.data.id); + case 'transaction.completed': + console.log('Transaction completed:', event.data.id); break; default: - console.log("Unhandled event:", event.event_type); + console.log('Unhandled event:', event.event_type); } // IMPORTANT: Respond within 5 seconds res.json({ received: true }); - }, + } ); function verifyPaddleSignature(payload, signature, secret) { - const parts = signature.split(";"); - const ts = parts.find((p) => p.startsWith("ts="))?.slice(3); + const parts = signature.split(';'); + const ts = parts.find(p => p.startsWith('ts='))?.slice(3); const signatures = parts - .filter((p) => p.startsWith("h1=")) - .map((p) => p.slice(3)); + .filter(p => p.startsWith('h1=')) + .map(p => p.slice(3)); if (!ts || signatures.length === 0) { return false; @@ -88,13 +87,16 @@ function verifyPaddleSignature(payload, signature, secret) { const signedPayload = `${ts}:${payload}`; const expectedSignature = crypto - .createHmac("sha256", secret) + .createHmac('sha256', secret) .update(signedPayload) - .digest("hex"); + .digest('hex'); // Check if any signature matches (handles secret rotation) - return signatures.some((sig) => - crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSignature)), + return signatures.some(sig => + crypto.timingSafeEqual( + Buffer.from(sig), + Buffer.from(expectedSignature) + ) ); } ``` @@ -113,13 +115,13 @@ webhook_secret = os.environ.get("PADDLE_WEBHOOK_SECRET") async def paddle_webhook(request: Request): payload = await request.body() signature = request.headers.get("paddle-signature") - + if not signature: raise HTTPException(status_code=400, detail="Missing signature") - + if not verify_paddle_signature(payload.decode(), signature, webhook_secret): raise HTTPException(status_code=400, detail="Invalid signature") - + event = await request.json() # Handle event... return {"received": True} @@ -148,24 +150,23 @@ def verify_paddle_signature(payload, signature, secret): ``` > **For complete working examples with tests**, see: -> > - [examples/express/](examples/express/) - Full Express implementation -> - [examples/nextjs/](examples/nextjs/) - Next.js App Router implementation +> - [examples/nextjs/](examples/nextjs/) - Next.js App Router implementation > - [examples/fastapi/](examples/fastapi/) - Python FastAPI implementation ## Common Event Types -| Event | Description | -| ---------------------------- | --------------------------------------- | -| `subscription.created` | New subscription created | -| `subscription.activated` | Subscription now active (first payment) | -| `subscription.canceled` | Subscription canceled | -| `subscription.paused` | Subscription paused | -| `subscription.resumed` | Subscription resumed from pause | -| `transaction.completed` | Transaction completed successfully | -| `transaction.payment_failed` | Payment attempt failed | -| `customer.created` | New customer created | -| `customer.updated` | Customer details updated | +| Event | Description | +|-------|-------------| +| `subscription.created` | New subscription created | +| `subscription.activated` | Subscription now active (first payment) | +| `subscription.canceled` | Subscription canceled | +| `subscription.paused` | Subscription paused | +| `subscription.resumed` | Subscription resumed from pause | +| `transaction.completed` | Transaction completed successfully | +| `transaction.payment_failed` | Payment attempt failed | +| `customer.created` | New customer created | +| `customer.updated` | Customer details updated | > **For full event reference**, see [Paddle Webhook Events](https://developer.paddle.com/webhooks/overview) diff --git a/.agents/skills/paddle-webhooks/TODO.md b/.agents/skills/paddle-webhooks/TODO.md index 880a85ba..9de9fd25 100644 --- a/.agents/skills/paddle-webhooks/TODO.md +++ b/.agents/skills/paddle-webhooks/TODO.md @@ -1,6 +1,6 @@ # TODO - Known Issues and Improvements -_Last updated: 2026-02-04_ +*Last updated: 2026-02-04* These items were identified during review. Most SDK-related issues have been fixed. @@ -17,3 +17,4 @@ These items were identified during review. Most SDK-related issues have been fix - [ ] **FastAPI SDK support**: The Python SDK's `Verifier` class is designed for Flask/Django. Consider adding native FastAPI support in future. - [ ] **Version constraints**: Consider tightening FastAPI version constraint from `>=0.100.0` to `>=0.128.0` + diff --git a/.agents/skills/paddle-webhooks/examples/express/README.md b/.agents/skills/paddle-webhooks/examples/express/README.md index 4d190209..ad9107ff 100644 --- a/.agents/skills/paddle-webhooks/examples/express/README.md +++ b/.agents/skills/paddle-webhooks/examples/express/README.md @@ -10,13 +10,11 @@ Minimal example of receiving Paddle webhooks with signature verification. ## Setup 1. Install dependencies: - ```bash npm install ``` 2. Copy environment variables: - ```bash cp .env.example .env ``` diff --git a/.agents/skills/paddle-webhooks/examples/express/test/webhook.test.js b/.agents/skills/paddle-webhooks/examples/express/test/webhook.test.js index cff7671e..b0e5d344 100644 --- a/.agents/skills/paddle-webhooks/examples/express/test/webhook.test.js +++ b/.agents/skills/paddle-webhooks/examples/express/test/webhook.test.js @@ -26,27 +26,27 @@ describe('Paddle Webhook Endpoint', () => { it('should return true for valid signature', () => { const payload = '{"event_type":"test"}'; const signature = generatePaddleSignature(payload, webhookSecret); - + expect(verifyPaddleSignature(payload, signature, webhookSecret)).toBe(true); }); it('should return false for invalid signature', () => { const payload = '{"event_type":"test"}'; const signature = 'ts=123;h1=invalid_signature'; - + expect(verifyPaddleSignature(payload, signature, webhookSecret)).toBe(false); }); it('should return false for missing signature header', () => { const payload = '{"event_type":"test"}'; - + expect(verifyPaddleSignature(payload, null, webhookSecret)).toBe(false); expect(verifyPaddleSignature(payload, undefined, webhookSecret)).toBe(false); }); it('should return false for malformed signature header', () => { const payload = '{"event_type":"test"}'; - + expect(verifyPaddleSignature(payload, 'invalid', webhookSecret)).toBe(false); expect(verifyPaddleSignature(payload, 'ts=123', webhookSecret)).toBe(false); }); @@ -86,7 +86,7 @@ describe('Paddle Webhook Endpoint', () => { event_type: 'subscription.created', data: { id: 'sub_test_123' } }); - + // Sign with original payload but send different payload const signature = generatePaddleSignature(originalPayload, webhookSecret); const tamperedPayload = JSON.stringify({ @@ -156,7 +156,7 @@ describe('Paddle Webhook Endpoint', () => { describe('GET /health', () => { it('should return health status', async () => { const response = await request(app).get('/health'); - + expect(response.status).toBe(200); expect(response.body).toEqual({ status: 'ok' }); }); diff --git a/.agents/skills/paddle-webhooks/examples/fastapi/README.md b/.agents/skills/paddle-webhooks/examples/fastapi/README.md index c4fd8783..eab89819 100644 --- a/.agents/skills/paddle-webhooks/examples/fastapi/README.md +++ b/.agents/skills/paddle-webhooks/examples/fastapi/README.md @@ -10,20 +10,17 @@ Minimal example of receiving Paddle webhooks with signature verification using P ## Setup 1. Create virtual environment: - ```bash python -m venv venv source venv/bin/activate # On Windows: venv\Scripts\activate ``` 2. Install dependencies: - ```bash pip install -r requirements.txt ``` 3. Copy environment variables: - ```bash cp .env.example .env ``` diff --git a/.agents/skills/paddle-webhooks/examples/nextjs/README.md b/.agents/skills/paddle-webhooks/examples/nextjs/README.md index 32235f04..c08fa598 100644 --- a/.agents/skills/paddle-webhooks/examples/nextjs/README.md +++ b/.agents/skills/paddle-webhooks/examples/nextjs/README.md @@ -10,13 +10,11 @@ Minimal example of receiving Paddle webhooks with signature verification using N ## Setup 1. Install dependencies: - ```bash npm install ``` 2. Copy environment variables: - ```bash cp .env.example .env.local ``` diff --git a/.agents/skills/paddle-webhooks/examples/nextjs/test/webhook.test.ts b/.agents/skills/paddle-webhooks/examples/nextjs/test/webhook.test.ts index 34b8ac6e..68cc54b6 100644 --- a/.agents/skills/paddle-webhooks/examples/nextjs/test/webhook.test.ts +++ b/.agents/skills/paddle-webhooks/examples/nextjs/test/webhook.test.ts @@ -67,26 +67,26 @@ describe('Paddle Webhook Signature Verification', () => { it('should return true for valid signature', () => { const payload = '{"event_type":"test"}'; const signature = generatePaddleSignature(payload, webhookSecret); - + expect(verifyPaddleSignature(payload, signature, webhookSecret)).toBe(true); }); it('should return false for invalid signature', () => { const payload = '{"event_type":"test"}'; const signature = 'ts=123;h1=invalid_signature'; - + expect(verifyPaddleSignature(payload, signature, webhookSecret)).toBe(false); }); it('should return false for missing signature header', () => { const payload = '{"event_type":"test"}'; - + expect(verifyPaddleSignature(payload, null, webhookSecret)).toBe(false); }); it('should return false for malformed signature header', () => { const payload = '{"event_type":"test"}'; - + expect(verifyPaddleSignature(payload, 'invalid', webhookSecret)).toBe(false); expect(verifyPaddleSignature(payload, 'ts=123', webhookSecret)).toBe(false); }); @@ -95,7 +95,7 @@ describe('Paddle Webhook Signature Verification', () => { const originalPayload = '{"event_type":"test","data":{"id":"123"}}'; const tamperedPayload = '{"event_type":"test","data":{"id":"456"}}'; const signature = generatePaddleSignature(originalPayload, webhookSecret); - + expect(verifyPaddleSignature(tamperedPayload, signature, webhookSecret)).toBe(false); }); @@ -103,15 +103,15 @@ describe('Paddle Webhook Signature Verification', () => { const payload = '{"event_type":"test"}'; const timestamp = Math.floor(Date.now() / 1000); const signedPayload = `${timestamp}:${payload}`; - + const validSignature = crypto .createHmac('sha256', webhookSecret) .update(signedPayload) .digest('hex'); - + // Include an old invalid signature and a new valid one const signature = `ts=${timestamp};h1=old_invalid_signature;h1=${validSignature}`; - + expect(verifyPaddleSignature(payload, signature, webhookSecret)).toBe(true); }); }); diff --git a/.agents/skills/paddle-webhooks/references/overview.md b/.agents/skills/paddle-webhooks/references/overview.md index eae3f8e4..1d61de86 100644 --- a/.agents/skills/paddle-webhooks/references/overview.md +++ b/.agents/skills/paddle-webhooks/references/overview.md @@ -8,20 +8,20 @@ When something notable occurs in your system, Paddle creates an event entity wit ## Common Event Types -| Event | Triggered When | Common Use Cases | -| ---------------------------- | ----------------------------------------------- | ---------------------------------------- | -| `subscription.created` | New subscription is created | Welcome emails, initial provisioning | -| `subscription.activated` | Subscription becomes active after first payment | Provision access, start trial conversion | -| `subscription.updated` | Subscription details change | Update entitlements, sync billing info | -| `subscription.canceled` | Subscription is canceled | Revoke access, retention emails | -| `subscription.paused` | Subscription is paused | Limit access, send pause confirmation | -| `subscription.resumed` | Subscription is resumed | Restore access, welcome back emails | -| `transaction.completed` | Transaction completes successfully | Fulfill orders, send receipts | -| `transaction.payment_failed` | Payment attempt fails | Dunning emails, retry notifications | -| `customer.created` | New customer is created | Welcome sequence, CRM sync | -| `customer.updated` | Customer details are updated | Update records, sync changes | -| `address.created` | New address is added | Update shipping, tax calculations | -| `business.created` | Business entity is created | B2B workflow triggers | +| Event | Triggered When | Common Use Cases | +|-------|----------------|------------------| +| `subscription.created` | New subscription is created | Welcome emails, initial provisioning | +| `subscription.activated` | Subscription becomes active after first payment | Provision access, start trial conversion | +| `subscription.updated` | Subscription details change | Update entitlements, sync billing info | +| `subscription.canceled` | Subscription is canceled | Revoke access, retention emails | +| `subscription.paused` | Subscription is paused | Limit access, send pause confirmation | +| `subscription.resumed` | Subscription is resumed | Restore access, welcome back emails | +| `transaction.completed` | Transaction completes successfully | Fulfill orders, send receipts | +| `transaction.payment_failed` | Payment attempt fails | Dunning emails, retry notifications | +| `customer.created` | New customer is created | Welcome sequence, CRM sync | +| `customer.updated` | Customer details are updated | Update records, sync changes | +| `address.created` | New address is added | Update shipping, tax calculations | +| `business.created` | Business entity is created | B2B workflow triggers | ## Event Payload Structure @@ -45,7 +45,6 @@ All Paddle webhook events share a common structure: ``` Key fields: - - `event_type` - The event type (e.g., `subscription.created`) - `data` - The full Paddle entity that triggered the event - `occurred_at` - When the event occurred (ISO 8601 timestamp) diff --git a/.agents/skills/paddle-webhooks/references/setup.md b/.agents/skills/paddle-webhooks/references/setup.md index 5001843a..228680d3 100644 --- a/.agents/skills/paddle-webhooks/references/setup.md +++ b/.agents/skills/paddle-webhooks/references/setup.md @@ -63,7 +63,6 @@ The response includes `endpoint_secret_key`. ## Recommended Events for Common Use Cases **Subscriptions:** - - `subscription.created` - `subscription.activated` - `subscription.updated` @@ -72,13 +71,11 @@ The response includes `endpoint_secret_key`. - `subscription.resumed` **Transactions:** - - `transaction.completed` - `transaction.payment_failed` - `transaction.billed` **Customers:** - - `customer.created` - `customer.updated` @@ -90,7 +87,6 @@ Paddle maintains separate environments: - **Live**: Production environment with real transactions. Each environment has: - - Different API endpoints (`sandbox-api.paddle.com` vs `api.paddle.com`) - Different webhook IP addresses (allowlist accordingly) - Separate notification destinations and secrets @@ -100,7 +96,6 @@ Each environment has: For security, you should allowlist Paddle's webhook IP addresses: **Sandbox:** - ``` 34.194.127.46 54.234.237.108 @@ -111,7 +106,6 @@ For security, you should allowlist Paddle's webhook IP addresses: ``` **Live:** - ``` 34.232.58.13 34.195.105.136 diff --git a/.agents/skills/paddle-webhooks/references/verification.md b/.agents/skills/paddle-webhooks/references/verification.md index 6745cf51..9cc71f9c 100644 --- a/.agents/skills/paddle-webhooks/references/verification.md +++ b/.agents/skills/paddle-webhooks/references/verification.md @@ -8,7 +8,6 @@ Paddle signs every webhook request using HMAC SHA-256. The signature is included 2. A signature (`h1`) - HMAC SHA-256 of `timestamp:payload` using your webhook secret Example header: - ``` Paddle-Signature: ts=1671552777;h1=eb4d0dc8853be92b7f063b9f3ba5233eb920a09459b6e6b2c26705b4364db151 ``` @@ -22,39 +21,30 @@ The `h1` signature is the current version. There may be multiple `h1` signatures The official Paddle SDKs handle signature verification automatically: **Node.js (`@paddle/paddle-node-sdk` v3.5.0+):** - ```javascript import { Paddle, EventName } from "@paddle/paddle-node-sdk"; const paddle = new Paddle(process.env.PADDLE_API_KEY); // Express middleware example -app.post( - "/webhooks/paddle", - express.raw({ type: "application/json" }), - async (req, res) => { - const signature = req.headers["paddle-signature"]; - const rawBody = req.body.toString(); - const secretKey = process.env.PADDLE_WEBHOOK_SECRET; - - try { - // The SDK handles verification and parsing in one step - // Method signature: paddle.webhooks.unmarshal(requestBody, secretKey, signature) - const event = await paddle.webhooks.unmarshal( - rawBody, - secretKey, - signature, - ); - - // Handle event - note: SDK returns camelCase properties - console.log(`Received event: ${event.eventType}`); - res.json({ received: true }); - } catch (err) { - console.error("Webhook verification failed:", err.message); - res.status(400).send("Invalid signature"); - } - }, -); +app.post('/webhooks/paddle', express.raw({ type: 'application/json' }), async (req, res) => { + const signature = req.headers['paddle-signature']; + const rawBody = req.body.toString(); + const secretKey = process.env.PADDLE_WEBHOOK_SECRET; + + try { + // The SDK handles verification and parsing in one step + // Method signature: paddle.webhooks.unmarshal(requestBody, secretKey, signature) + const event = await paddle.webhooks.unmarshal(rawBody, secretKey, signature); + + // Handle event - note: SDK returns camelCase properties + console.log(`Received event: ${event.eventType}`); + res.json({ received: true }); + } catch (err) { + console.error('Webhook verification failed:', err.message); + res.status(400).send('Invalid signature'); + } +}); ``` **Python (`paddle-billing` v1.13.0+):** @@ -68,14 +58,14 @@ from paddle_billing.Notifications import Secret, Verifier @app.route("/webhooks/paddle", methods=["POST"]) def paddle_webhook(): webhook_secret = os.environ['PADDLE_WEBHOOK_SECRET'] - + # The Verifier handles signature verification # Method signature: Verifier().verify(request, Secret(secret)) is_valid = Verifier().verify(request, Secret(webhook_secret)) - + if not is_valid: return "Invalid signature", 400 - + # Parse and handle event event = request.get_json() print(f"Received event: {event['event_type']}") @@ -89,36 +79,34 @@ def paddle_webhook(): If you need to verify manually: **Node.js:** - ```javascript -const crypto = require("crypto"); +const crypto = require('crypto'); function verifyPaddleSignature(payload, signatureHeader, secret) { // Parse the signature header - const parts = signatureHeader.split(";"); - const timestamp = parts.find((p) => p.startsWith("ts=")).slice(3); + const parts = signatureHeader.split(';'); + const timestamp = parts.find(p => p.startsWith('ts=')).slice(3); const signatures = parts - .filter((p) => p.startsWith("h1=")) - .map((p) => p.slice(3)); + .filter(p => p.startsWith('h1=')) + .map(p => p.slice(3)); // Build the signed payload (timestamp:rawBody) const signedPayload = `${timestamp}:${payload}`; // Compute the expected signature const expectedSignature = crypto - .createHmac("sha256", secret) + .createHmac('sha256', secret) .update(signedPayload) - .digest("hex"); + .digest('hex'); // Check if any signature matches (handles rotation) - return signatures.some((sig) => - crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSignature)), + return signatures.some(sig => + crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSignature)) ); } ``` **Python:** - ```python import hmac import hashlib @@ -159,7 +147,6 @@ def verify_paddle_signature(payload: str, signature_header: str, secret: str) -> The most common cause of verification failures is using a parsed JSON body instead of the raw request body. **Express:** - ```javascript // WRONG - body is already parsed app.use(express.json()); @@ -209,12 +196,12 @@ function isTimestampValid(timestamp, toleranceSeconds = 5) { Paddle requires a response within **5 seconds**. Respond before doing any processing, then handle the event asynchronously. ```javascript -app.post("/webhooks/paddle", async (req, res) => { +app.post('/webhooks/paddle', async (req, res) => { // Verify signature... - + // Respond immediately res.json({ received: true }); - + // Then process asynchronously processEventAsync(event); }); @@ -231,20 +218,13 @@ app.post("/webhooks/paddle", async (req, res) => { ### Logging for Debugging ```javascript -app.post( - "/webhooks/paddle", - express.raw({ type: "application/json" }), - (req, res) => { - console.log("Body type:", typeof req.body); - console.log( - "Body (first 100 chars):", - req.body.toString().substring(0, 100), - ); - console.log("Signature header:", req.headers["paddle-signature"]); - - // Verify... - }, -); +app.post('/webhooks/paddle', express.raw({ type: 'application/json' }), (req, res) => { + console.log('Body type:', typeof req.body); + console.log('Body (first 100 chars):', req.body.toString().substring(0, 100)); + console.log('Signature header:', req.headers['paddle-signature']); + + // Verify... +}); ``` ## Retry Behavior diff --git a/.agents/skills/tailwind-design-system/SKILL.md b/.agents/skills/tailwind-design-system/SKILL.md index 0a8f806f..4dcf9f92 100644 --- a/.agents/skills/tailwind-design-system/SKILL.md +++ b/.agents/skills/tailwind-design-system/SKILL.md @@ -528,207 +528,10 @@ export function Container({ className, size, ...props }: ContainerProps) { ``` -### Pattern 5: Native CSS Animations (v4) +For advanced animation and dark mode patterns, see [references/advanced-patterns.md](references/advanced-patterns.md): -```css -/* In your CSS file - native @starting-style for entry animations */ -@theme { - --animate-dialog-in: dialog-fade-in 0.2s ease-out; - --animate-dialog-out: dialog-fade-out 0.15s ease-in; -} - -@keyframes dialog-fade-in { - from { - opacity: 0; - transform: scale(0.95) translateY(-0.5rem); - } - to { - opacity: 1; - transform: scale(1) translateY(0); - } -} - -@keyframes dialog-fade-out { - from { - opacity: 1; - transform: scale(1) translateY(0); - } - to { - opacity: 0; - transform: scale(0.95) translateY(-0.5rem); - } -} - -/* Native popover animations using @starting-style */ -[popover] { - transition: - opacity 0.2s, - transform 0.2s, - display 0.2s allow-discrete; - opacity: 0; - transform: scale(0.95); -} - -[popover]:popover-open { - opacity: 1; - transform: scale(1); -} - -@starting-style { - [popover]:popover-open { - opacity: 0; - transform: scale(0.95); - } -} -``` - -```typescript -// components/ui/dialog.tsx - Using native popover API -import * as DialogPrimitive from '@radix-ui/react-dialog' -import { cn } from '@/lib/utils' - -const DialogPortal = DialogPrimitive.Portal - -export function DialogOverlay({ - className, - ref, - ...props -}: React.ComponentPropsWithoutRef & { - ref?: React.Ref -}) { - return ( - - ) -} - -export function DialogContent({ - className, - children, - ref, - ...props -}: React.ComponentPropsWithoutRef & { - ref?: React.Ref -}) { - return ( - - - - {children} - - - ) -} -``` - -### Pattern 6: Dark Mode with CSS (v4) - -```typescript -// providers/ThemeProvider.tsx - Simplified for v4 -'use client' - -import { createContext, useContext, useEffect, useState } from 'react' - -type Theme = 'dark' | 'light' | 'system' - -interface ThemeContextType { - theme: Theme - setTheme: (theme: Theme) => void - resolvedTheme: 'dark' | 'light' -} - -const ThemeContext = createContext(undefined) - -export function ThemeProvider({ - children, - defaultTheme = 'system', - storageKey = 'theme', -}: { - children: React.ReactNode - defaultTheme?: Theme - storageKey?: string -}) { - const [theme, setTheme] = useState(defaultTheme) - const [resolvedTheme, setResolvedTheme] = useState<'dark' | 'light'>('light') - - useEffect(() => { - const stored = localStorage.getItem(storageKey) as Theme | null - if (stored) setTheme(stored) - }, [storageKey]) - - useEffect(() => { - const root = document.documentElement - root.classList.remove('light', 'dark') - - const resolved = theme === 'system' - ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') - : theme - - root.classList.add(resolved) - setResolvedTheme(resolved) - - // Update meta theme-color for mobile browsers - const metaThemeColor = document.querySelector('meta[name="theme-color"]') - if (metaThemeColor) { - metaThemeColor.setAttribute('content', resolved === 'dark' ? '#09090b' : '#ffffff') - } - }, [theme]) - - return ( - { - localStorage.setItem(storageKey, newTheme) - setTheme(newTheme) - }, - resolvedTheme, - }}> - {children} - - ) -} - -export const useTheme = () => { - const context = useContext(ThemeContext) - if (!context) throw new Error('useTheme must be used within ThemeProvider') - return context -} - -// components/ThemeToggle.tsx -import { Moon, Sun } from 'lucide-react' -import { useTheme } from '@/providers/ThemeProvider' - -export function ThemeToggle() { - const { resolvedTheme, setTheme } = useTheme() - - return ( - - ) -} -``` +- **Pattern 5: Native CSS Animations** — dialog `@keyframes`, native popover API with `@starting-style`, `allow-discrete` transitions, and a full `DialogContent`/`DialogOverlay` implementation using Radix UI +- **Pattern 6: Dark Mode** — `ThemeProvider` context with `localStorage` persistence, `prefers-color-scheme` detection, meta `theme-color` update, and a `ThemeToggle` button component ## Utility Functions @@ -751,124 +554,12 @@ export const focusRing = cn( export const disabled = "disabled:pointer-events-none disabled:opacity-50"; ``` -## Advanced v4 Patterns - -### Custom Utilities with `@utility` - -Define reusable custom utilities: - -```css -/* Custom utility for decorative lines */ -@utility line-t { - @apply relative before:absolute before:top-0 before:-left-[100vw] before:h-px before:w-[200vw] before:bg-gray-950/5 dark:before:bg-white/10; -} - -/* Custom utility for text gradients */ -@utility text-gradient { - @apply bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent; -} -``` - -### Theme Modifiers - -```css -/* Use @theme inline when referencing other CSS variables */ -@theme inline { - --font-sans: var(--font-inter), system-ui; -} - -/* Use @theme static to always generate CSS variables (even when unused) */ -@theme static { - --color-brand: oklch(65% 0.15 240); -} - -/* Import with theme options */ -@import "tailwindcss" theme(static); -``` - -### Namespace Overrides - -```css -@theme { - /* Clear all default colors and define your own */ - --color-*: initial; - --color-white: #fff; - --color-black: #000; - --color-primary: oklch(45% 0.2 260); - --color-secondary: oklch(65% 0.15 200); - - /* Clear ALL defaults for a minimal setup */ - /* --*: initial; */ -} -``` - -### Semi-transparent Color Variants - -```css -@theme { - /* Use color-mix() for alpha variants */ - --color-primary-50: color-mix(in oklab, var(--color-primary) 5%, transparent); - --color-primary-100: color-mix( - in oklab, - var(--color-primary) 10%, - transparent - ); - --color-primary-200: color-mix( - in oklab, - var(--color-primary) 20%, - transparent - ); -} -``` - -### Container Queries - -```css -@theme { - --container-xs: 20rem; - --container-sm: 24rem; - --container-md: 28rem; - --container-lg: 32rem; -} -``` +For advanced v4 CSS patterns, the full v3-to-v4 migration checklist, and complete best practices, see [references/advanced-patterns.md](references/advanced-patterns.md): -## v3 to v4 Migration Checklist - -- [ ] Replace `tailwind.config.ts` with CSS `@theme` block -- [ ] Change `@tailwind base/components/utilities` to `@import "tailwindcss"` -- [ ] Move color definitions to `@theme { --color-*: value }` -- [ ] Replace `darkMode: "class"` with `@custom-variant dark` -- [ ] Move `@keyframes` inside `@theme` blocks (ensures keyframes output with theme) -- [ ] Replace `require("tailwindcss-animate")` with native CSS animations -- [ ] Update `h-10 w-10` to `size-10` (new utility) -- [ ] Remove `forwardRef` (React 19 passes ref as prop) -- [ ] Consider OKLCH colors for better color perception -- [ ] Replace custom plugins with `@utility` directives - -## Best Practices - -### Do's - -- **Use `@theme` blocks** - CSS-first configuration is v4's core pattern -- **Use OKLCH colors** - Better perceptual uniformity than HSL -- **Compose with CVA** - Type-safe variants -- **Use semantic tokens** - `bg-primary` not `bg-blue-500` -- **Use `size-*`** - New shorthand for `w-* h-*` -- **Add accessibility** - ARIA attributes, focus states - -### Don'ts - -- **Don't use `tailwind.config.ts`** - Use CSS `@theme` instead -- **Don't use `@tailwind` directives** - Use `@import "tailwindcss"` -- **Don't use `forwardRef`** - React 19 passes ref as prop -- **Don't use arbitrary values** - Extend `@theme` instead -- **Don't hardcode colors** - Use semantic tokens -- **Don't forget dark mode** - Test both themes - -## Resources - -- [Tailwind CSS v4 Documentation](https://tailwindcss.com/docs) -- [Tailwind v4 Beta Announcement](https://tailwindcss.com/blog/tailwindcss-v4-beta) -- [CVA Documentation](https://cva.style/docs) -- [shadcn/ui](https://ui.shadcn.com/) -- [Radix Primitives](https://www.radix-ui.com/primitives) +- **Custom `@utility`** — reusable CSS utilities for decorative lines and text gradients +- **Theme modifiers** — `@theme inline` (reference other CSS vars), `@theme static` (always output), `@import "tailwindcss" theme(static)` +- **Namespace overrides** — clearing default Tailwind color scales with `--color-*: initial` +- **Semi-transparent variants** — `color-mix()` for alpha scale generation +- **Container queries** — `--container-*` token definitions +- **v3→v4 migration checklist** — 10-item checklist covering config, directives, colors, dark mode, animations, React 19 ref changes +- **Best practices** — full Do's and Don'ts list diff --git a/.agents/skills/tailwind-design-system/references/advanced-patterns.md b/.agents/skills/tailwind-design-system/references/advanced-patterns.md new file mode 100644 index 00000000..6b420784 --- /dev/null +++ b/.agents/skills/tailwind-design-system/references/advanced-patterns.md @@ -0,0 +1,319 @@ +# Tailwind Design System: Advanced Patterns + +Advanced Tailwind CSS v4 patterns including animations, dark mode theming, custom utilities, theme modifiers, namespace overrides, and the v3-to-v4 migration checklist. + +## Pattern 5: Native CSS Animations (v4) + +```css +/* In your CSS file - native @starting-style for entry animations */ +@theme { + --animate-dialog-in: dialog-fade-in 0.2s ease-out; + --animate-dialog-out: dialog-fade-out 0.15s ease-in; +} + +@keyframes dialog-fade-in { + from { + opacity: 0; + transform: scale(0.95) translateY(-0.5rem); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +@keyframes dialog-fade-out { + from { + opacity: 1; + transform: scale(1) translateY(0); + } + to { + opacity: 0; + transform: scale(0.95) translateY(-0.5rem); + } +} + +/* Native popover animations using @starting-style */ +[popover] { + transition: + opacity 0.2s, + transform 0.2s, + display 0.2s allow-discrete; + opacity: 0; + transform: scale(0.95); +} + +[popover]:popover-open { + opacity: 1; + transform: scale(1); +} + +@starting-style { + [popover]:popover-open { + opacity: 0; + transform: scale(0.95); + } +} +``` + +```typescript +// components/ui/dialog.tsx - Using native popover API +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { cn } from '@/lib/utils' + +const DialogPortal = DialogPrimitive.Portal + +export function DialogOverlay({ + className, + ref, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.Ref +}) { + return ( + + ) +} + +export function DialogContent({ + className, + children, + ref, + ...props +}: React.ComponentPropsWithoutRef & { + ref?: React.Ref +}) { + return ( + + + + {children} + + + ) +} +``` + +## Pattern 6: Dark Mode with CSS (v4) + +```typescript +// providers/ThemeProvider.tsx - Simplified for v4 +'use client' + +import { createContext, useContext, useEffect, useState } from 'react' + +type Theme = 'dark' | 'light' | 'system' + +interface ThemeContextType { + theme: Theme + setTheme: (theme: Theme) => void + resolvedTheme: 'dark' | 'light' +} + +const ThemeContext = createContext(undefined) + +export function ThemeProvider({ + children, + defaultTheme = 'system', + storageKey = 'theme', +}: { + children: React.ReactNode + defaultTheme?: Theme + storageKey?: string +}) { + const [theme, setTheme] = useState(defaultTheme) + const [resolvedTheme, setResolvedTheme] = useState<'dark' | 'light'>('light') + + useEffect(() => { + const stored = localStorage.getItem(storageKey) as Theme | null + if (stored) setTheme(stored) + }, [storageKey]) + + useEffect(() => { + const root = document.documentElement + root.classList.remove('light', 'dark') + + const resolved = theme === 'system' + ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') + : theme + + root.classList.add(resolved) + setResolvedTheme(resolved) + + // Update meta theme-color for mobile browsers + const metaThemeColor = document.querySelector('meta[name="theme-color"]') + if (metaThemeColor) { + metaThemeColor.setAttribute('content', resolved === 'dark' ? '#09090b' : '#ffffff') + } + }, [theme]) + + return ( + { + localStorage.setItem(storageKey, newTheme) + setTheme(newTheme) + }, + resolvedTheme, + }}> + {children} + + ) +} + +export const useTheme = () => { + const context = useContext(ThemeContext) + if (!context) throw new Error('useTheme must be used within ThemeProvider') + return context +} + +// components/ThemeToggle.tsx +import { Moon, Sun } from 'lucide-react' +import { useTheme } from '@/providers/ThemeProvider' + +export function ThemeToggle() { + const { resolvedTheme, setTheme } = useTheme() + + return ( + + ) +} +``` + +## Advanced v4 Patterns + +### Custom Utilities with `@utility` + +Define reusable custom utilities: + +```css +/* Custom utility for decorative lines */ +@utility line-t { + @apply relative before:absolute before:top-0 before:-left-[100vw] before:h-px before:w-[200vw] before:bg-gray-950/5 dark:before:bg-white/10; +} + +/* Custom utility for text gradients */ +@utility text-gradient { + @apply bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent; +} +``` + +### Theme Modifiers + +```css +/* Use @theme inline when referencing other CSS variables */ +@theme inline { + --font-sans: var(--font-inter), system-ui; +} + +/* Use @theme static to always generate CSS variables (even when unused) */ +@theme static { + --color-brand: oklch(65% 0.15 240); +} + +/* Import with theme options */ +@import "tailwindcss" theme(static); +``` + +### Namespace Overrides + +```css +@theme { + /* Clear all default colors and define your own */ + --color-*: initial; + --color-white: #fff; + --color-black: #000; + --color-primary: oklch(45% 0.2 260); + --color-secondary: oklch(65% 0.15 200); + + /* Clear ALL defaults for a minimal setup */ + /* --*: initial; */ +} +``` + +### Semi-transparent Color Variants + +```css +@theme { + /* Use color-mix() for alpha variants */ + --color-primary-50: color-mix(in oklab, var(--color-primary) 5%, transparent); + --color-primary-100: color-mix( + in oklab, + var(--color-primary) 10%, + transparent + ); + --color-primary-200: color-mix( + in oklab, + var(--color-primary) 20%, + transparent + ); +} +``` + +### Container Queries + +```css +@theme { + --container-xs: 20rem; + --container-sm: 24rem; + --container-md: 28rem; + --container-lg: 32rem; +} +``` + +## v3 to v4 Migration Checklist + +- [ ] Replace `tailwind.config.ts` with CSS `@theme` block +- [ ] Change `@tailwind base/components/utilities` to `@import "tailwindcss"` +- [ ] Move color definitions to `@theme { --color-*: value }` +- [ ] Replace `darkMode: "class"` with `@custom-variant dark` +- [ ] Move `@keyframes` inside `@theme` blocks (ensures keyframes output with theme) +- [ ] Replace `require("tailwindcss-animate")` with native CSS animations +- [ ] Update `h-10 w-10` to `size-10` (new utility) +- [ ] Remove `forwardRef` (React 19 passes ref as prop) +- [ ] Consider OKLCH colors for better color perception +- [ ] Replace custom plugins with `@utility` directives + +## Best Practices + +### Do's + +- **Use `@theme` blocks** - CSS-first configuration is v4's core pattern +- **Use OKLCH colors** - Better perceptual uniformity than HSL +- **Compose with CVA** - Type-safe variants +- **Use semantic tokens** - `bg-primary` not `bg-blue-500` +- **Use `size-*`** - New shorthand for `w-* h-*` +- **Add accessibility** - ARIA attributes, focus states + +### Don'ts + +- **Don't use `tailwind.config.ts`** - Use CSS `@theme` instead +- **Don't use `@tailwind` directives** - Use `@import "tailwindcss"` +- **Don't use `forwardRef`** - React 19 passes ref as prop +- **Don't use arbitrary values** - Extend `@theme` instead +- **Don't hardcode colors** - Use semantic tokens +- **Don't forget dark mode** - Test both themes diff --git a/.agents/skills/vercel-react-best-practices/AGENTS.md b/.agents/skills/vercel-react-best-practices/AGENTS.md index e97f5abb..4e340a50 100644 --- a/.agents/skills/vercel-react-best-practices/AGENTS.md +++ b/.agents/skills/vercel-react-best-practices/AGENTS.md @@ -21,25 +21,30 @@ Comprehensive performance optimization guide for React and Next.js applications, ## Table of Contents 1. [Eliminating Waterfalls](#1-eliminating-waterfalls) — **CRITICAL** - - 1.1 [Defer Await Until Needed](#11-defer-await-until-needed) - - 1.2 [Dependency-Based Parallelization](#12-dependency-based-parallelization) - - 1.3 [Prevent Waterfall Chains in API Routes](#13-prevent-waterfall-chains-in-api-routes) - - 1.4 [Promise.all() for Independent Operations](#14-promiseall-for-independent-operations) - - 1.5 [Strategic Suspense Boundaries](#15-strategic-suspense-boundaries) + - 1.1 [Check Cheap Conditions Before Async Flags](#11-check-cheap-conditions-before-async-flags) + - 1.2 [Defer Await Until Needed](#12-defer-await-until-needed) + - 1.3 [Dependency-Based Parallelization](#13-dependency-based-parallelization) + - 1.4 [Prevent Waterfall Chains in API Routes](#14-prevent-waterfall-chains-in-api-routes) + - 1.5 [Promise.all() for Independent Operations](#15-promiseall-for-independent-operations) + - 1.6 [Strategic Suspense Boundaries](#16-strategic-suspense-boundaries) 2. [Bundle Size Optimization](#2-bundle-size-optimization) — **CRITICAL** - 2.1 [Avoid Barrel File Imports](#21-avoid-barrel-file-imports) - 2.2 [Conditional Module Loading](#22-conditional-module-loading) - 2.3 [Defer Non-Critical Third-Party Libraries](#23-defer-non-critical-third-party-libraries) - 2.4 [Dynamic Imports for Heavy Components](#24-dynamic-imports-for-heavy-components) - - 2.5 [Preload Based on User Intent](#25-preload-based-on-user-intent) + - 2.5 [Prefer Statically Analyzable Paths](#25-prefer-statically-analyzable-paths) + - 2.6 [Preload Based on User Intent](#26-preload-based-on-user-intent) 3. [Server-Side Performance](#3-server-side-performance) — **HIGH** - 3.1 [Authenticate Server Actions Like API Routes](#31-authenticate-server-actions-like-api-routes) - 3.2 [Avoid Duplicate Serialization in RSC Props](#32-avoid-duplicate-serialization-in-rsc-props) - - 3.3 [Cross-Request LRU Caching](#33-cross-request-lru-caching) - - 3.4 [Minimize Serialization at RSC Boundaries](#34-minimize-serialization-at-rsc-boundaries) - - 3.5 [Parallel Data Fetching with Component Composition](#35-parallel-data-fetching-with-component-composition) - - 3.6 [Per-Request Deduplication with React.cache()](#36-per-request-deduplication-with-reactcache) - - 3.7 [Use after() for Non-Blocking Operations](#37-use-after-for-non-blocking-operations) + - 3.3 [Avoid Shared Module State for Request Data](#33-avoid-shared-module-state-for-request-data) + - 3.4 [Cross-Request LRU Caching](#34-cross-request-lru-caching) + - 3.5 [Hoist Static I/O to Module Level](#35-hoist-static-io-to-module-level) + - 3.6 [Minimize Serialization at RSC Boundaries](#36-minimize-serialization-at-rsc-boundaries) + - 3.7 [Parallel Data Fetching with Component Composition](#37-parallel-data-fetching-with-component-composition) + - 3.8 [Parallel Nested Data Fetching](#38-parallel-nested-data-fetching) + - 3.9 [Per-Request Deduplication with React.cache()](#39-per-request-deduplication-with-reactcache) + - 3.10 [Use after() for Non-Blocking Operations](#310-use-after-for-non-blocking-operations) 4. [Client-Side Data Fetching](#4-client-side-data-fetching) — **MEDIUM-HIGH** - 4.1 [Deduplicate Global Event Listeners](#41-deduplicate-global-event-listeners) - 4.2 [Use Passive Event Listeners for Scrolling Performance](#42-use-passive-event-listeners-for-scrolling-performance) @@ -49,15 +54,18 @@ Comprehensive performance optimization guide for React and Next.js applications, - 5.1 [Calculate Derived State During Rendering](#51-calculate-derived-state-during-rendering) - 5.2 [Defer State Reads to Usage Point](#52-defer-state-reads-to-usage-point) - 5.3 [Do not wrap a simple expression with a primitive result type in useMemo](#53-do-not-wrap-a-simple-expression-with-a-primitive-result-type-in-usememo) - - 5.4 [Extract Default Non-primitive Parameter Value from Memoized Component to Constant](#54-extract-default-non-primitive-parameter-value-from-memoized-component-to-constant) - - 5.5 [Extract to Memoized Components](#55-extract-to-memoized-components) - - 5.6 [Narrow Effect Dependencies](#56-narrow-effect-dependencies) - - 5.7 [Put Interaction Logic in Event Handlers](#57-put-interaction-logic-in-event-handlers) - - 5.8 [Subscribe to Derived State](#58-subscribe-to-derived-state) - - 5.9 [Use Functional setState Updates](#59-use-functional-setstate-updates) - - 5.10 [Use Lazy State Initialization](#510-use-lazy-state-initialization) - - 5.11 [Use Transitions for Non-Urgent Updates](#511-use-transitions-for-non-urgent-updates) - - 5.12 [Use useRef for Transient Values](#512-use-useref-for-transient-values) + - 5.4 [Don't Define Components Inside Components](#54-dont-define-components-inside-components) + - 5.5 [Extract Default Non-primitive Parameter Value from Memoized Component to Constant](#55-extract-default-non-primitive-parameter-value-from-memoized-component-to-constant) + - 5.6 [Extract to Memoized Components](#56-extract-to-memoized-components) + - 5.7 [Narrow Effect Dependencies](#57-narrow-effect-dependencies) + - 5.8 [Put Interaction Logic in Event Handlers](#58-put-interaction-logic-in-event-handlers) + - 5.9 [Split Combined Hook Computations](#59-split-combined-hook-computations) + - 5.10 [Subscribe to Derived State](#510-subscribe-to-derived-state) + - 5.11 [Use Functional setState Updates](#511-use-functional-setstate-updates) + - 5.12 [Use Lazy State Initialization](#512-use-lazy-state-initialization) + - 5.13 [Use Transitions for Non-Urgent Updates](#513-use-transitions-for-non-urgent-updates) + - 5.14 [Use useDeferredValue for Expensive Derived Renders](#514-use-usedeferredvalue-for-expensive-derived-renders) + - 5.15 [Use useRef for Transient Values](#515-use-useref-for-transient-values) 6. [Rendering Performance](#6-rendering-performance) — **MEDIUM** - 6.1 [Animate SVG Wrapper Instead of SVG Element](#61-animate-svg-wrapper-instead-of-svg-element) - 6.2 [CSS content-visibility for Long Lists](#62-css-content-visibility-for-long-lists) @@ -66,8 +74,10 @@ Comprehensive performance optimization guide for React and Next.js applications, - 6.5 [Prevent Hydration Mismatch Without Flickering](#65-prevent-hydration-mismatch-without-flickering) - 6.6 [Suppress Expected Hydration Mismatches](#66-suppress-expected-hydration-mismatches) - 6.7 [Use Activity Component for Show/Hide](#67-use-activity-component-for-showhide) - - 6.8 [Use Explicit Conditional Rendering](#68-use-explicit-conditional-rendering) - - 6.9 [Use useTransition Over Manual Loading States](#69-use-usetransition-over-manual-loading-states) + - 6.8 [Use defer or async on Script Tags](#68-use-defer-or-async-on-script-tags) + - 6.9 [Use Explicit Conditional Rendering](#69-use-explicit-conditional-rendering) + - 6.10 [Use React DOM Resource Hints](#610-use-react-dom-resource-hints) + - 6.11 [Use useTransition Over Manual Loading States](#611-use-usetransition-over-manual-loading-states) 7. [JavaScript Performance](#7-javascript-performance) — **LOW-MEDIUM** - 7.1 [Avoid Layout Thrashing](#71-avoid-layout-thrashing) - 7.2 [Build Index Maps for Repeated Lookups](#72-build-index-maps-for-repeated-lookups) @@ -75,16 +85,19 @@ Comprehensive performance optimization guide for React and Next.js applications, - 7.4 [Cache Repeated Function Calls](#74-cache-repeated-function-calls) - 7.5 [Cache Storage API Calls](#75-cache-storage-api-calls) - 7.6 [Combine Multiple Array Iterations](#76-combine-multiple-array-iterations) - - 7.7 [Early Length Check for Array Comparisons](#77-early-length-check-for-array-comparisons) - - 7.8 [Early Return from Functions](#78-early-return-from-functions) - - 7.9 [Hoist RegExp Creation](#79-hoist-regexp-creation) - - 7.10 [Use Loop for Min/Max Instead of Sort](#710-use-loop-for-minmax-instead-of-sort) - - 7.11 [Use Set/Map for O(1) Lookups](#711-use-setmap-for-o1-lookups) - - 7.12 [Use toSorted() Instead of sort() for Immutability](#712-use-tosorted-instead-of-sort-for-immutability) + - 7.7 [Defer Non-Critical Work with requestIdleCallback](#77-defer-non-critical-work-with-requestidlecallback) + - 7.8 [Early Length Check for Array Comparisons](#78-early-length-check-for-array-comparisons) + - 7.9 [Early Return from Functions](#79-early-return-from-functions) + - 7.10 [Hoist RegExp Creation](#710-hoist-regexp-creation) + - 7.11 [Use flatMap to Map and Filter in One Pass](#711-use-flatmap-to-map-and-filter-in-one-pass) + - 7.12 [Use Loop for Min/Max Instead of Sort](#712-use-loop-for-minmax-instead-of-sort) + - 7.13 [Use Set/Map for O(1) Lookups](#713-use-setmap-for-o1-lookups) + - 7.14 [Use toSorted() Instead of sort() for Immutability](#714-use-tosorted-instead-of-sort-for-immutability) 8. [Advanced Patterns](#8-advanced-patterns) — **LOW** - - 8.1 [Initialize App Once, Not Per Mount](#81-initialize-app-once-not-per-mount) - - 8.2 [Store Event Handlers in Refs](#82-store-event-handlers-in-refs) - - 8.3 [useEffectEvent for Stable Callback Refs](#83-useeffectevent-for-stable-callback-refs) + - 8.1 [Do Not Put Effect Events in Dependency Arrays](#81-do-not-put-effect-events-in-dependency-arrays) + - 8.2 [Initialize App Once, Not Per Mount](#82-initialize-app-once-not-per-mount) + - 8.3 [Store Event Handlers in Refs](#83-store-event-handlers-in-refs) + - 8.4 [useEffectEvent for Stable Callback Refs](#84-useeffectevent-for-stable-callback-refs) --- @@ -94,7 +107,40 @@ Comprehensive performance optimization guide for React and Next.js applications, Waterfalls are the #1 performance killer. Each sequential await adds full network latency. Eliminating them yields the largest gains. -### 1.1 Defer Await Until Needed +### 1.1 Check Cheap Conditions Before Async Flags + +**Impact: HIGH (avoids unnecessary async work when a synchronous guard already fails)** + +When a branch uses `await` for a flag or remote value and also requires a **cheap synchronous** condition (local props, request metadata, already-loaded state), evaluate the cheap condition **first**. Otherwise you pay for the async call even when the compound condition can never be true. + +This is a specialization of [Defer Await Until Needed](./async-defer-await.md) for `flag && cheapCondition` style checks. + +**Incorrect:** + +```typescript +const someFlag = await getFlag() + +if (someFlag && someCondition) { + // ... +} +``` + +**Correct:** + +```typescript +if (someCondition) { + const someFlag = await getFlag() + if (someFlag) { + // ... + } +} +``` + +This matters when `getFlag` hits the network, a feature-flag service, or `React.cache` / DB work: skipping it when `someCondition` is false removes that cost on the cold path. + +Keep the original order if `someCondition` is expensive, depends on the flag, or you must run side effects in a fixed order. + +### 1.2 Defer Await Until Needed **Impact: HIGH (avoids blocking unused code paths)** @@ -104,15 +150,15 @@ Move `await` operations into the branches where they're actually used to avoid b ```typescript async function handleRequest(userId: string, skipProcessing: boolean) { - const userData = await fetchUserData(userId); - + const userData = await fetchUserData(userId) + if (skipProcessing) { // Returns immediately but still waited for userData - return { skipped: true }; + return { skipped: true } } - + // Only this branch uses userData - return processUserData(userData); + return processUserData(userData) } ``` @@ -122,12 +168,12 @@ async function handleRequest(userId: string, skipProcessing: boolean) { async function handleRequest(userId: string, skipProcessing: boolean) { if (skipProcessing) { // Returns immediately without waiting - return { skipped: true }; + return { skipped: true } } - + // Fetch only when needed - const userData = await fetchUserData(userId); - return processUserData(userData); + const userData = await fetchUserData(userId) + return processUserData(userData) } ``` @@ -136,41 +182,43 @@ async function handleRequest(userId: string, skipProcessing: boolean) { ```typescript // Incorrect: always fetches permissions async function updateResource(resourceId: string, userId: string) { - const permissions = await fetchPermissions(userId); - const resource = await getResource(resourceId); - + const permissions = await fetchPermissions(userId) + const resource = await getResource(resourceId) + if (!resource) { - return { error: "Not found" }; + return { error: 'Not found' } } - + if (!permissions.canEdit) { - return { error: "Forbidden" }; + return { error: 'Forbidden' } } - - return await updateResourceData(resource, permissions); + + return await updateResourceData(resource, permissions) } // Correct: fetches only when needed async function updateResource(resourceId: string, userId: string) { - const resource = await getResource(resourceId); - + const resource = await getResource(resourceId) + if (!resource) { - return { error: "Not found" }; + return { error: 'Not found' } } - - const permissions = await fetchPermissions(userId); - + + const permissions = await fetchPermissions(userId) + if (!permissions.canEdit) { - return { error: "Forbidden" }; + return { error: 'Forbidden' } } - - return await updateResourceData(resource, permissions); + + return await updateResourceData(resource, permissions) } ``` This optimization is especially valuable when the skipped branch is frequently taken, or when the deferred operation is expensive. -### 1.2 Dependency-Based Parallelization +For `await getFlag()` combined with a cheap synchronous guard (`flag && someCondition`), see [Check Cheap Conditions Before Async Flags](./async-cheap-condition-before-await.md). + +### 1.3 Dependency-Based Parallelization **Impact: CRITICAL (2-10× improvement)** @@ -179,46 +227,45 @@ For operations with partial dependencies, use `better-all` to maximize paralleli **Incorrect: profile waits for config unnecessarily** ```typescript -const [user, config] = await Promise.all([fetchUser(), fetchConfig()]); -const profile = await fetchProfile(user.id); +const [user, config] = await Promise.all([ + fetchUser(), + fetchConfig() +]) +const profile = await fetchProfile(user.id) ``` **Correct: config and profile run in parallel** ```typescript -import { all } from "better-all"; +import { all } from 'better-all' const { user, config, profile } = await all({ - async user() { - return fetchUser(); - }, - async config() { - return fetchConfig(); - }, + async user() { return fetchUser() }, + async config() { return fetchConfig() }, async profile() { - return fetchProfile((await this.$.user).id); - }, -}); + return fetchProfile((await this.$.user).id) + } +}) ``` **Alternative without extra dependencies:** ```typescript -const userPromise = fetchUser(); -const profilePromise = userPromise.then((user) => fetchProfile(user.id)); +const userPromise = fetchUser() +const profilePromise = userPromise.then(user => fetchProfile(user.id)) const [user, config, profile] = await Promise.all([ userPromise, fetchConfig(), - profilePromise, -]); + profilePromise +]) ``` We can also create all the promises first, and do `Promise.all()` at the end. Reference: [https://github.com/shuding/better-all](https://github.com/shuding/better-all) -### 1.3 Prevent Waterfall Chains in API Routes +### 1.4 Prevent Waterfall Chains in API Routes **Impact: CRITICAL (2-10× improvement)** @@ -228,10 +275,10 @@ In API routes and Server Actions, start independent operations immediately, even ```typescript export async function GET(request: Request) { - const session = await auth(); - const config = await fetchConfig(); - const data = await fetchData(session.user.id); - return Response.json({ data, config }); + const session = await auth() + const config = await fetchConfig() + const data = await fetchData(session.user.id) + return Response.json({ data, config }) } ``` @@ -239,20 +286,20 @@ export async function GET(request: Request) { ```typescript export async function GET(request: Request) { - const sessionPromise = auth(); - const configPromise = fetchConfig(); - const session = await sessionPromise; + const sessionPromise = auth() + const configPromise = fetchConfig() + const session = await sessionPromise const [config, data] = await Promise.all([ configPromise, - fetchData(session.user.id), - ]); - return Response.json({ data, config }); + fetchData(session.user.id) + ]) + return Response.json({ data, config }) } ``` For operations with more complex dependency chains, use `better-all` to automatically maximize parallelism (see Dependency-Based Parallelization). -### 1.4 Promise.all() for Independent Operations +### 1.5 Promise.all() for Independent Operations **Impact: CRITICAL (2-10× improvement)** @@ -261,9 +308,9 @@ When async operations have no interdependencies, execute them concurrently using **Incorrect: sequential execution, 3 round trips** ```typescript -const user = await fetchUser(); -const posts = await fetchPosts(); -const comments = await fetchComments(); +const user = await fetchUser() +const posts = await fetchPosts() +const comments = await fetchComments() ``` **Correct: parallel execution, 1 round trip** @@ -272,11 +319,11 @@ const comments = await fetchComments(); const [user, posts, comments] = await Promise.all([ fetchUser(), fetchPosts(), - fetchComments(), -]); + fetchComments() +]) ``` -### 1.5 Strategic Suspense Boundaries +### 1.6 Strategic Suspense Boundaries **Impact: HIGH (faster initial paint)** @@ -286,8 +333,8 @@ Instead of awaiting data in async components before returning JSX, use Suspense ```tsx async function Page() { - const data = await fetchData(); // Blocks entire page - + const data = await fetchData() // Blocks entire page + return (
Sidebar
@@ -297,7 +344,7 @@ async function Page() {
Footer
- ); + ) } ``` @@ -318,12 +365,12 @@ function Page() {
Footer
- ); + ) } async function DataDisplay() { - const data = await fetchData(); // Only blocks this component - return
{data.content}
; + const data = await fetchData() // Only blocks this component + return
{data.content}
} ``` @@ -334,8 +381,8 @@ Sidebar, Header, and Footer render immediately. Only DataDisplay waits for data. ```tsx function Page() { // Start fetch immediately, but don't await - const dataPromise = fetchData(); - + const dataPromise = fetchData() + return (
Sidebar
@@ -346,17 +393,17 @@ function Page() {
Footer
- ); + ) } function DataDisplay({ dataPromise }: { dataPromise: Promise }) { - const data = use(dataPromise); // Unwraps the promise - return
{data.content}
; + const data = use(dataPromise) // Unwraps the promise + return
{data.content}
} function DataSummary({ dataPromise }: { dataPromise: Promise }) { - const data = use(dataPromise); // Reuses the same promise - return
{data.summary}
; + const data = use(dataPromise) // Reuses the same promise + return
{data.summary}
} ``` @@ -395,43 +442,35 @@ Popular icon and component libraries can have **up to 10,000 re-exports** in the **Incorrect: imports entire library** ```tsx -import { Check, X, Menu } from "lucide-react"; +import { Check, X, Menu } from 'lucide-react' // Loads 1,583 modules, takes ~2.8s extra in dev // Runtime cost: 200-800ms on every cold start -import { Button, TextField } from "@mui/material"; +import { Button, TextField } from '@mui/material' // Loads 2,225 modules, takes ~4.2s extra in dev ``` -**Correct: imports only what you need** +**Correct - Next.js 13.5+ (recommended):** ```tsx -import Check from "lucide-react/dist/esm/icons/check"; -import X from "lucide-react/dist/esm/icons/x"; -import Menu from "lucide-react/dist/esm/icons/menu"; -// Loads only 3 modules (~2KB vs ~1MB) - -import Button from "@mui/material/Button"; -import TextField from "@mui/material/TextField"; -// Loads only what you use +// Keep the standard imports - Next.js transforms them to direct imports +import { Check, X, Menu } from 'lucide-react' +// Full TypeScript support, no manual path wrangling ``` -**Alternative: Next.js 13.5+** +This is the recommended approach because it preserves TypeScript type safety and editor autocompletion while still eliminating the barrel import cost. -```js -// next.config.js - use optimizePackageImports -module.exports = { - experimental: { - optimizePackageImports: ["lucide-react", "@mui/material"], - }, -}; +**Correct - Direct imports (non-Next.js projects):** -// Then you can keep the ergonomic barrel imports: -import { Check, X, Menu } from "lucide-react"; -// Automatically transformed to direct imports at build time +```tsx +import Button from '@mui/material/Button' +import TextField from '@mui/material/TextField' +// Loads only what you use ``` -Direct imports provide 15-70% faster dev boot, 28% faster builds, 40% faster cold starts, and significantly faster HMR. +> **TypeScript warning:** Some libraries (notably `lucide-react`) don't ship `.d.ts` files for their deep import paths. Importing from `lucide-react/dist/esm/icons/check` resolves to an implicit `any` type, causing errors under `strict` or `noImplicitAny`. Prefer `optimizePackageImports` when available, or verify the library exports types for its subpaths before using direct imports. + +These optimizations provide 15-70% faster dev boot, 28% faster builds, 40% faster cold starts, and significantly faster HMR. Libraries commonly affected: `lucide-react`, `@mui/material`, `@mui/icons-material`, `@tabler/icons-react`, `react-icons`, `@headlessui/react`, `@radix-ui/react-*`, `lodash`, `ramda`, `date-fns`, `rxjs`, `react-use`. @@ -446,25 +485,19 @@ Load large data or modules only when a feature is activated. **Example: lazy-load animation frames** ```tsx -function AnimationPlayer({ - enabled, - setEnabled, -}: { - enabled: boolean; - setEnabled: React.Dispatch>; -}) { - const [frames, setFrames] = useState(null); +function AnimationPlayer({ enabled, setEnabled }: { enabled: boolean; setEnabled: React.Dispatch> }) { + const [frames, setFrames] = useState(null) useEffect(() => { - if (enabled && !frames && typeof window !== "undefined") { - import("./animation-frames.js") - .then((mod) => setFrames(mod.frames)) - .catch(() => setEnabled(false)); + if (enabled && !frames && typeof window !== 'undefined') { + import('./animation-frames.js') + .then(mod => setFrames(mod.frames)) + .catch(() => setEnabled(false)) } - }, [enabled, frames, setEnabled]); + }, [enabled, frames, setEnabled]) - if (!frames) return ; - return ; + if (!frames) return + return } ``` @@ -479,7 +512,7 @@ Analytics, logging, and error tracking don't block user interaction. Load them a **Incorrect: blocks initial bundle** ```tsx -import { Analytics } from "@vercel/analytics/react"; +import { Analytics } from '@vercel/analytics/react' export default function RootLayout({ children }) { return ( @@ -489,19 +522,19 @@ export default function RootLayout({ children }) { - ); + ) } ``` **Correct: loads after hydration** ```tsx -import dynamic from "next/dynamic"; +import dynamic from 'next/dynamic' const Analytics = dynamic( - () => import("@vercel/analytics/react").then((m) => m.Analytics), - { ssr: false }, -); + () => import('@vercel/analytics/react').then(m => m.Analytics), + { ssr: false } +) export default function RootLayout({ children }) { return ( @@ -511,7 +544,7 @@ export default function RootLayout({ children }) { - ); + ) } ``` @@ -524,29 +557,88 @@ Use `next/dynamic` to lazy-load large components not needed on initial render. **Incorrect: Monaco bundles with main chunk ~300KB** ```tsx -import { MonacoEditor } from "./monaco-editor"; +import { MonacoEditor } from './monaco-editor' function CodePanel({ code }: { code: string }) { - return ; + return } ``` **Correct: Monaco loads on demand** ```tsx -import dynamic from "next/dynamic"; +import dynamic from 'next/dynamic' const MonacoEditor = dynamic( - () => import("./monaco-editor").then((m) => m.MonacoEditor), - { ssr: false }, -); + () => import('./monaco-editor').then(m => m.MonacoEditor), + { ssr: false } +) function CodePanel({ code }: { code: string }) { - return ; + return } ``` -### 2.5 Preload Based on User Intent +### 2.5 Prefer Statically Analyzable Paths + +**Impact: HIGH (avoids accidental broad bundles and file traces)** + +Build tools work best when import and file-system paths are obvious at build time. If you hide the real path inside a variable or compose it too dynamically, the tool either has to include a broad set of possible files, warn that it cannot analyze the import, or widen file tracing to stay safe. + +Prefer explicit maps or literal paths so the set of reachable files stays narrow and predictable. This is the same rule whether you are choosing modules with `import()` or reading files in server/build code. + +When analysis becomes too broad, the cost is real: + +- Larger server bundles + +- Slower builds + +- Worse cold starts + +- More memory use + +**Incorrect: the bundler cannot tell what may be imported** + +```ts +const PAGE_MODULES = { + home: './pages/home', + settings: './pages/settings', +} as const + +const Page = await import(PAGE_MODULES[pageName]) +``` + +**Correct: use an explicit map of allowed modules** + +```ts +const PAGE_MODULES = { + home: () => import('./pages/home'), + settings: () => import('./pages/settings'), +} as const + +const Page = await PAGE_MODULES[pageName]() +``` + +**Incorrect: a 2-value enum still hides the final path from static analysis** + +```ts +const baseDir = path.join(process.cwd(), 'content/' + contentKind) +``` + +**Correct: make each final path literal at the callsite** + +```ts +const baseDir = + kind === ContentKind.Blog + ? path.join(process.cwd(), 'content/blog') + : path.join(process.cwd(), 'content/docs') +``` + +In Next.js server code, this matters for output file tracing too. `path.join(process.cwd(), someVar)` can widen the traced file set because Next.js statically analyze `import`, `require`, and `fs` usage. + +Reference: [https://nextjs.org/docs/app/api-reference/config/next-config-js/output](https://nextjs.org/docs/app/api-reference/config/next-config-js/output), [https://nextjs.org/learn/seo/dynamic-imports](https://nextjs.org/learn/seo/dynamic-imports), [https://vite.dev/guide/features.html](https://vite.dev/guide/features.html), [https://esbuild.github.io/api/](https://esbuild.github.io/api/), [https://www.npmjs.com/package/@rollup/plugin-dynamic-import-vars](https://www.npmjs.com/package/@rollup/plugin-dynamic-import-vars), [https://webpack.js.org/guides/dependency-management/](https://webpack.js.org/guides/dependency-management/) + +### 2.6 Preload Based on User Intent **Impact: MEDIUM (reduces perceived latency)** @@ -557,16 +649,20 @@ Preload heavy bundles before they're needed to reduce perceived latency. ```tsx function EditorButton({ onClick }: { onClick: () => void }) { const preload = () => { - if (typeof window !== "undefined") { - void import("./monaco-editor"); + if (typeof window !== 'undefined') { + void import('./monaco-editor') } - }; + } return ( - - ); + ) } ``` @@ -575,14 +671,14 @@ function EditorButton({ onClick }: { onClick: () => void }) { ```tsx function FlagsProvider({ children, flags }: Props) { useEffect(() => { - if (flags.editorEnabled && typeof window !== "undefined") { - void import("./monaco-editor").then((mod) => mod.init()); + if (flags.editorEnabled && typeof window !== 'undefined') { + void import('./monaco-editor').then(mod => mod.init()) } - }, [flags.editorEnabled]); + }, [flags.editorEnabled]) - return ( - {children} - ); + return + {children} + } ``` @@ -607,80 +703,80 @@ Next.js documentation explicitly states: "Treat Server Actions with the same sec **Incorrect: no authentication check** ```typescript -"use server"; +'use server' export async function deleteUser(userId: string) { // Anyone can call this! No auth check - await db.user.delete({ where: { id: userId } }); - return { success: true }; + await db.user.delete({ where: { id: userId } }) + return { success: true } } ``` **Correct: authentication inside the action** ```typescript -"use server"; +'use server' -import { verifySession } from "@/lib/auth"; -import { unauthorized } from "@/lib/errors"; +import { verifySession } from '@/lib/auth' +import { unauthorized } from '@/lib/errors' export async function deleteUser(userId: string) { // Always check auth inside the action - const session = await verifySession(); - + const session = await verifySession() + if (!session) { - throw unauthorized("Must be logged in"); + throw unauthorized('Must be logged in') } - + // Check authorization too - if (session.user.role !== "admin" && session.user.id !== userId) { - throw unauthorized("Cannot delete other users"); + if (session.user.role !== 'admin' && session.user.id !== userId) { + throw unauthorized('Cannot delete other users') } - - await db.user.delete({ where: { id: userId } }); - return { success: true }; + + await db.user.delete({ where: { id: userId } }) + return { success: true } } ``` **With input validation:** ```typescript -"use server"; +'use server' -import { verifySession } from "@/lib/auth"; -import { z } from "zod"; +import { verifySession } from '@/lib/auth' +import { z } from 'zod' const updateProfileSchema = z.object({ userId: z.string().uuid(), name: z.string().min(1).max(100), - email: z.string().email(), -}); + email: z.string().email() +}) export async function updateProfile(data: unknown) { // Validate input first - const validated = updateProfileSchema.parse(data); - + const validated = updateProfileSchema.parse(data) + // Then authenticate - const session = await verifySession(); + const session = await verifySession() if (!session) { - throw new Error("Unauthorized"); + throw new Error('Unauthorized') } - + // Then authorize if (session.user.id !== validated.userId) { - throw new Error("Can only update own profile"); + throw new Error('Can only update own profile') } - + // Finally perform the mutation await db.user.update({ where: { id: validated.userId }, data: { name: validated.name, - email: validated.email, - }, - }); - - return { success: true }; + email: validated.email + } + }) + + return { success: true } } ``` @@ -703,11 +799,11 @@ RSC→client serialization deduplicates by object reference, not value. Same ref ```tsx // RSC: send once -; + // Client: transform there -("use client"); -const sorted = useMemo(() => [...usernames].sort(), [usernames]); +'use client' +const sorted = useMemo(() => [...usernames].sort(), [usernames]) ``` **Nested deduplication behavior:** @@ -747,7 +843,55 @@ Deduplication works recursively. Impact varies by data type: **Exception:** Pass derived data when transformation is expensive or client doesn't need original. -### 3.3 Cross-Request LRU Caching +### 3.3 Avoid Shared Module State for Request Data + +**Impact: HIGH (prevents concurrency bugs and request data leaks)** + +For React Server Components and client components rendered during SSR, avoid using mutable module-level variables to share request-scoped data. Server renders can run concurrently in the same process. If one render writes to shared module state and another render reads it, you can get race conditions, cross-request contamination, and security bugs where one user's data appears in another user's response. + +Treat module scope on the server as process-wide shared memory, not request-local state. + +**Incorrect: request data leaks across concurrent renders** + +```tsx +let currentUser: User | null = null + +export default async function Page() { + currentUser = await auth() + return +} + +async function Dashboard() { + return
{currentUser?.name}
+} +``` + +If two requests overlap, request A can set `currentUser`, then request B overwrites it before request A finishes rendering `Dashboard`. + +**Correct: keep request data local to the render tree** + +```tsx +export default async function Page() { + const user = await auth() + return +} + +function Dashboard({ user }: { user: User | null }) { + return
{user?.name}
+} +``` + +Safe exceptions: + +- Immutable static assets or config loaded once at module scope + +- Shared caches intentionally designed for cross-request reuse and keyed correctly + +- Process-wide singletons that do not store request- or user-specific mutable data + +For static assets and config, see [Hoist Static I/O to Module Level](./server-hoist-static-io.md). + +### 3.4 Cross-Request LRU Caching **Impact: HIGH (caches across requests)** @@ -756,20 +900,20 @@ Deduplication works recursively. Impact varies by data type: **Implementation:** ```typescript -import { LRUCache } from "lru-cache"; +import { LRUCache } from 'lru-cache' const cache = new LRUCache({ max: 1000, - ttl: 5 * 60 * 1000, // 5 minutes -}); + ttl: 5 * 60 * 1000 // 5 minutes +}) export async function getUser(id: string) { - const cached = cache.get(id); - if (cached) return cached; + const cached = cache.get(id) + if (cached) return cached - const user = await db.user.findUnique({ where: { id } }); - cache.set(id, user); - return user; + const user = await db.user.findUnique({ where: { id } }) + cache.set(id, user) + return user } // Request 1: DB query, result cached @@ -784,7 +928,157 @@ Use when sequential user actions hit multiple endpoints needing the same data wi Reference: [https://github.com/isaacs/node-lru-cache](https://github.com/isaacs/node-lru-cache) -### 3.4 Minimize Serialization at RSC Boundaries +### 3.5 Hoist Static I/O to Module Level + +**Impact: HIGH (avoids repeated file/network I/O per request)** + +When loading static assets (fonts, logos, images, config files) in route handlers or server functions, hoist the I/O operation to module level. Module-level code runs once when the module is first imported, not on every request. This eliminates redundant file system reads or network fetches that would otherwise run on every invocation. + +**Incorrect: reads font file on every request** + +```typescript +// app/api/og/route.tsx +import { ImageResponse } from 'next/og' + +export async function GET(request: Request) { + // Runs on EVERY request - expensive! + const fontData = await fetch( + new URL('./fonts/Inter.ttf', import.meta.url) + ).then(res => res.arrayBuffer()) + + const logoData = await fetch( + new URL('./images/logo.png', import.meta.url) + ).then(res => res.arrayBuffer()) + + return new ImageResponse( +
+ + Hello World +
, + { fonts: [{ name: 'Inter', data: fontData }] } + ) +} +``` + +**Correct: loads once at module initialization** + +```typescript +// app/api/og/route.tsx +import { ImageResponse } from 'next/og' + +// Module-level: runs ONCE when module is first imported +const fontData = fetch( + new URL('./fonts/Inter.ttf', import.meta.url) +).then(res => res.arrayBuffer()) + +const logoData = fetch( + new URL('./images/logo.png', import.meta.url) +).then(res => res.arrayBuffer()) + +export async function GET(request: Request) { + // Await the already-started promises + const [font, logo] = await Promise.all([fontData, logoData]) + + return new ImageResponse( +
+ + Hello World +
, + { fonts: [{ name: 'Inter', data: font }] } + ) +} +``` + +**Correct: synchronous fs at module level** + +```typescript +// app/api/og/route.tsx +import { ImageResponse } from 'next/og' +import { readFileSync } from 'fs' +import { join } from 'path' + +// Synchronous read at module level - blocks only during module init +const fontData = readFileSync( + join(process.cwd(), 'public/fonts/Inter.ttf') +) + +const logoData = readFileSync( + join(process.cwd(), 'public/images/logo.png') +) + +export async function GET(request: Request) { + return new ImageResponse( +
+ + Hello World +
, + { fonts: [{ name: 'Inter', data: fontData }] } + ) +} +``` + +**Incorrect: reads config on every call** + +```typescript +import fs from 'node:fs/promises' + +export async function processRequest(data: Data) { + const config = JSON.parse( + await fs.readFile('./config.json', 'utf-8') + ) + const template = await fs.readFile('./template.html', 'utf-8') + + return render(template, data, config) +} +``` + +**Correct: hoists config and template to module level** + +```typescript +import fs from 'node:fs/promises' + +const configPromise = fs + .readFile('./config.json', 'utf-8') + .then(JSON.parse) +const templatePromise = fs.readFile('./template.html', 'utf-8') + +export async function processRequest(data: Data) { + const [config, template] = await Promise.all([ + configPromise, + templatePromise, + ]) + + return render(template, data, config) +} +``` + +When to use this pattern: + +- Loading fonts for OG image generation + +- Loading static logos, icons, or watermarks + +- Reading configuration files that don't change at runtime + +- Loading email templates or other static templates + +- Any static asset that's the same across all requests + +When not to use this pattern: + +- Assets that vary per request or user + +- Files that may change during runtime (use caching with TTL instead) + +- Large files that would consume too much memory if kept loaded + +- Sensitive data that shouldn't persist in memory + +With Vercel's [Fluid Compute](https://vercel.com/docs/fluid-compute), module-level caching is especially effective because multiple concurrent requests share the same function instance. The static assets stay loaded in memory across requests without cold start penalties. + +In traditional serverless, each cold start re-executes module-level code, but subsequent warm invocations reuse the loaded assets until the instance is recycled. + +### 3.6 Minimize Serialization at RSC Boundaries **Impact: HIGH (reduces data transfer size)** @@ -794,13 +1088,13 @@ The React Server/Client boundary serializes all object properties into strings a ```tsx async function Page() { - const user = await fetchUser(); // 50 fields - return ; + const user = await fetchUser() // 50 fields + return } -("use client"); +'use client' function Profile({ user }: { user: User }) { - return
{user.name}
; // uses 1 field + return
{user.name}
// uses 1 field } ``` @@ -808,17 +1102,17 @@ function Profile({ user }: { user: User }) { ```tsx async function Page() { - const user = await fetchUser(); - return ; + const user = await fetchUser() + return } -("use client"); +'use client' function Profile({ name }: { name: string }) { - return
{name}
; + return
{name}
} ``` -### 3.5 Parallel Data Fetching with Component Composition +### 3.7 Parallel Data Fetching with Component Composition **Impact: CRITICAL (eliminates server-side waterfalls)** @@ -828,18 +1122,18 @@ React Server Components execute sequentially within a tree. Restructure with com ```tsx export default async function Page() { - const header = await fetchHeader(); + const header = await fetchHeader() return (
{header}
- ); + ) } async function Sidebar() { - const items = await fetchSidebarItems(); - return ; + const items = await fetchSidebarItems() + return } ``` @@ -847,13 +1141,13 @@ async function Sidebar() { ```tsx async function Header() { - const data = await fetchHeader(); - return
{data}
; + const data = await fetchHeader() + return
{data}
} async function Sidebar() { - const items = await fetchSidebarItems(); - return ; + const items = await fetchSidebarItems() + return } export default function Page() { @@ -862,7 +1156,7 @@ export default function Page() {
- ); + ) } ``` @@ -870,13 +1164,13 @@ export default function Page() { ```tsx async function Header() { - const data = await fetchHeader(); - return
{data}
; + const data = await fetchHeader() + return
{data}
} async function Sidebar() { - const items = await fetchSidebarItems(); - return ; + const items = await fetchSidebarItems() + return } function Layout({ children }: { children: ReactNode }) { @@ -885,7 +1179,7 @@ function Layout({ children }: { children: ReactNode }) {
{children} - ); + ) } export default function Page() { @@ -893,11 +1187,41 @@ export default function Page() { - ); + ) } ``` -### 3.6 Per-Request Deduplication with React.cache() +### 3.8 Parallel Nested Data Fetching + +**Impact: CRITICAL (eliminates server-side waterfalls)** + +When fetching nested data in parallel, chain dependent fetches within each item's promise so a slow item doesn't block the rest. + +**Incorrect: a single slow item blocks all nested fetches** + +```tsx +const chats = await Promise.all( + chatIds.map(id => getChat(id)) +) + +const chatAuthors = await Promise.all( + chats.map(chat => getUser(chat.author)) +) +``` + +If one `getChat(id)` out of 100 is extremely slow, the authors of the other 99 chats can't start loading even though their data is ready. + +**Correct: each item chains its own nested fetch** + +```tsx +const chatAuthors = await Promise.all( + chatIds.map(id => getChat(id).then(chat => getUser(chat.author))) +) +``` + +Each item independently chains `getChat` → `getUser`, so a slow chat doesn't block author fetches for the others. + +### 3.9 Per-Request Deduplication with React.cache() **Impact: MEDIUM (deduplicates within request)** @@ -906,15 +1230,15 @@ Use `React.cache()` for server-side request deduplication. Authentication and da **Usage:** ```typescript -import { cache } from "react"; +import { cache } from 'react' export const getCurrentUser = cache(async () => { - const session = await auth(); - if (!session?.user?.id) return null; + const session = await auth() + if (!session?.user?.id) return null return await db.user.findUnique({ - where: { id: session.user.id }, - }); -}); + where: { id: session.user.id } + }) +}) ``` Within a single request, multiple calls to `getCurrentUser()` execute the query only once. @@ -927,20 +1251,20 @@ Within a single request, multiple calls to `getCurrentUser()` execute the query ```typescript const getUser = cache(async (params: { uid: number }) => { - return await db.user.findUnique({ where: { id: params.uid } }); -}); + return await db.user.findUnique({ where: { id: params.uid } }) +}) // Each call creates new object, never hits cache -getUser({ uid: 1 }); -getUser({ uid: 1 }); // Cache miss, runs query again +getUser({ uid: 1 }) +getUser({ uid: 1 }) // Cache miss, runs query again ``` **Correct: cache hit** ```typescript -const params = { uid: 1 }; -getUser(params); // Query runs -getUser(params); // Cache hit (same reference) +const params = { uid: 1 } +getUser(params) // Query runs +getUser(params) // Cache hit (same reference) ``` If you must pass objects, pass the same reference: @@ -963,7 +1287,7 @@ Use `React.cache()` to deduplicate these operations across your component tree. Reference: [https://react.dev/reference/react/cache](https://react.dev/reference/react/cache) -### 3.7 Use after() for Non-Blocking Operations +### 3.10 Use after() for Non-Blocking Operations **Impact: MEDIUM (faster response times)** @@ -972,47 +1296,46 @@ Use Next.js's `after()` to schedule work that should execute after a response is **Incorrect: blocks response** ```tsx -import { logUserAction } from "@/app/utils"; +import { logUserAction } from '@/app/utils' export async function POST(request: Request) { // Perform mutation - await updateDatabase(request); - + await updateDatabase(request) + // Logging blocks the response - const userAgent = request.headers.get("user-agent") || "unknown"; - await logUserAction({ userAgent }); - - return new Response(JSON.stringify({ status: "success" }), { + const userAgent = request.headers.get('user-agent') || 'unknown' + await logUserAction({ userAgent }) + + return new Response(JSON.stringify({ status: 'success' }), { status: 200, - headers: { "Content-Type": "application/json" }, - }); + headers: { 'Content-Type': 'application/json' } + }) } ``` **Correct: non-blocking** ```tsx -import { after } from "next/server"; -import { headers, cookies } from "next/headers"; -import { logUserAction } from "@/app/utils"; +import { after } from 'next/server' +import { headers, cookies } from 'next/headers' +import { logUserAction } from '@/app/utils' export async function POST(request: Request) { // Perform mutation - await updateDatabase(request); - + await updateDatabase(request) + // Log after response is sent after(async () => { - const userAgent = (await headers()).get("user-agent") || "unknown"; - const sessionCookie = - (await cookies()).get("session-id")?.value || "anonymous"; - - logUserAction({ sessionCookie, userAgent }); - }); - - return new Response(JSON.stringify({ status: "success" }), { + const userAgent = (await headers()).get('user-agent') || 'unknown' + const sessionCookie = (await cookies()).get('session-id')?.value || 'anonymous' + + logUserAction({ sessionCookie, userAgent }) + }) + + return new Response(JSON.stringify({ status: 'success' }), { status: 200, - headers: { "Content-Type": "application/json" }, - }); + headers: { 'Content-Type': 'application/json' } + }) } ``` @@ -1059,12 +1382,12 @@ function useKeyboardShortcut(key: string, callback: () => void) { useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.metaKey && e.key === key) { - callback(); + callback() } - }; - window.addEventListener("keydown", handler); - return () => window.removeEventListener("keydown", handler); - }, [key, callback]); + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [key, callback]) } ``` @@ -1073,49 +1396,45 @@ When using the `useKeyboardShortcut` hook multiple times, each instance will reg **Correct: N instances = 1 listener** ```tsx -import useSWRSubscription from "swr/subscription"; +import useSWRSubscription from 'swr/subscription' // Module-level Map to track callbacks per key -const keyCallbacks = new Map void>>(); +const keyCallbacks = new Map void>>() function useKeyboardShortcut(key: string, callback: () => void) { // Register this callback in the Map useEffect(() => { if (!keyCallbacks.has(key)) { - keyCallbacks.set(key, new Set()); + keyCallbacks.set(key, new Set()) } - keyCallbacks.get(key)!.add(callback); + keyCallbacks.get(key)!.add(callback) return () => { - const set = keyCallbacks.get(key); + const set = keyCallbacks.get(key) if (set) { - set.delete(callback); + set.delete(callback) if (set.size === 0) { - keyCallbacks.delete(key); + keyCallbacks.delete(key) } } - }; - }, [key, callback]); + } + }, [key, callback]) - useSWRSubscription("global-keydown", () => { + useSWRSubscription('global-keydown', () => { const handler = (e: KeyboardEvent) => { if (e.metaKey && keyCallbacks.has(e.key)) { - keyCallbacks.get(e.key)!.forEach((cb) => cb()); + keyCallbacks.get(e.key)!.forEach(cb => cb()) } - }; - window.addEventListener("keydown", handler); - return () => window.removeEventListener("keydown", handler); - }); + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }) } function Profile() { // Multiple shortcuts will share the same listener - useKeyboardShortcut("p", () => { - /* ... */ - }); - useKeyboardShortcut("k", () => { - /* ... */ - }); + useKeyboardShortcut('p', () => { /* ... */ }) + useKeyboardShortcut('k', () => { /* ... */ }) // ... } ``` @@ -1130,34 +1449,34 @@ Add `{ passive: true }` to touch and wheel event listeners to enable immediate s ```typescript useEffect(() => { - const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX); - const handleWheel = (e: WheelEvent) => console.log(e.deltaY); - - document.addEventListener("touchstart", handleTouch); - document.addEventListener("wheel", handleWheel); - + const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX) + const handleWheel = (e: WheelEvent) => console.log(e.deltaY) + + document.addEventListener('touchstart', handleTouch) + document.addEventListener('wheel', handleWheel) + return () => { - document.removeEventListener("touchstart", handleTouch); - document.removeEventListener("wheel", handleWheel); - }; -}, []); + document.removeEventListener('touchstart', handleTouch) + document.removeEventListener('wheel', handleWheel) + } +}, []) ``` **Correct:** ```typescript useEffect(() => { - const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX); - const handleWheel = (e: WheelEvent) => console.log(e.deltaY); - - document.addEventListener("touchstart", handleTouch, { passive: true }); - document.addEventListener("wheel", handleWheel, { passive: true }); - + const handleTouch = (e: TouchEvent) => console.log(e.touches[0].clientX) + const handleWheel = (e: WheelEvent) => console.log(e.deltaY) + + document.addEventListener('touchstart', handleTouch, { passive: true }) + document.addEventListener('wheel', handleWheel, { passive: true }) + return () => { - document.removeEventListener("touchstart", handleTouch); - document.removeEventListener("wheel", handleWheel); - }; -}, []); + document.removeEventListener('touchstart', handleTouch) + document.removeEventListener('wheel', handleWheel) + } +}, []) ``` **Use passive when:** tracking/analytics, logging, any listener that doesn't call `preventDefault()`. @@ -1174,43 +1493,43 @@ SWR enables request deduplication, caching, and revalidation across component in ```tsx function UserList() { - const [users, setUsers] = useState([]); + const [users, setUsers] = useState([]) useEffect(() => { - fetch("/api/users") - .then((r) => r.json()) - .then(setUsers); - }, []); + fetch('/api/users') + .then(r => r.json()) + .then(setUsers) + }, []) } ``` **Correct: multiple instances share one request** ```tsx -import useSWR from "swr"; +import useSWR from 'swr' function UserList() { - const { data: users } = useSWR("/api/users", fetcher); + const { data: users } = useSWR('/api/users', fetcher) } ``` **For immutable data:** ```tsx -import { useImmutableSWR } from "@/lib/swr"; +import { useImmutableSWR } from '@/lib/swr' function StaticContent() { - const { data } = useImmutableSWR("/api/config", fetcher); + const { data } = useImmutableSWR('/api/config', fetcher) } ``` **For mutations:** ```tsx -import { useSWRMutation } from "swr/mutation"; +import { useSWRMutation } from 'swr/mutation' function UpdateButton() { - const { trigger } = useSWRMutation("/api/user", updateUser); - return ; + const { trigger } = useSWRMutation('/api/user', updateUser) + return } ``` @@ -1226,18 +1545,18 @@ Add version prefix to keys and store only needed fields. Prevents schema conflic ```typescript // No version, stores everything, no error handling -localStorage.setItem("userConfig", JSON.stringify(fullUserObject)); -const data = localStorage.getItem("userConfig"); +localStorage.setItem('userConfig', JSON.stringify(fullUserObject)) +const data = localStorage.getItem('userConfig') ``` **Correct:** ```typescript -const VERSION = "v2"; +const VERSION = 'v2' function saveConfig(config: { theme: string; language: string }) { try { - localStorage.setItem(`userConfig:${VERSION}`, JSON.stringify(config)); + localStorage.setItem(`userConfig:${VERSION}`, JSON.stringify(config)) } catch { // Throws in incognito/private browsing, quota exceeded, or disabled } @@ -1245,24 +1564,21 @@ function saveConfig(config: { theme: string; language: string }) { function loadConfig() { try { - const data = localStorage.getItem(`userConfig:${VERSION}`); - return data ? JSON.parse(data) : null; + const data = localStorage.getItem(`userConfig:${VERSION}`) + return data ? JSON.parse(data) : null } catch { - return null; + return null } } // Migration from v1 to v2 function migrate() { try { - const v1 = localStorage.getItem("userConfig:v1"); + const v1 = localStorage.getItem('userConfig:v1') if (v1) { - const old = JSON.parse(v1); - saveConfig({ - theme: old.darkMode ? "dark" : "light", - language: old.lang, - }); - localStorage.removeItem("userConfig:v1"); + const old = JSON.parse(v1) + saveConfig({ theme: old.darkMode ? 'dark' : 'light', language: old.lang }) + localStorage.removeItem('userConfig:v1') } } catch {} } @@ -1274,13 +1590,10 @@ function migrate() { // User object has 20+ fields, only store what UI needs function cachePrefs(user: FullUser) { try { - localStorage.setItem( - "prefs:v1", - JSON.stringify({ - theme: user.preferences.theme, - notifications: user.preferences.notifications, - }), - ); + localStorage.setItem('prefs:v1', JSON.stringify({ + theme: user.preferences.theme, + notifications: user.preferences.notifications + })) } catch {} } ``` @@ -1307,15 +1620,15 @@ If a value can be computed from current props/state, do not store it in state or ```tsx function Form() { - const [firstName, setFirstName] = useState("First"); - const [lastName, setLastName] = useState("Last"); - const [fullName, setFullName] = useState(""); + const [firstName, setFirstName] = useState('First') + const [lastName, setLastName] = useState('Last') + const [fullName, setFullName] = useState('') useEffect(() => { - setFullName(firstName + " " + lastName); - }, [firstName, lastName]); + setFullName(firstName + ' ' + lastName) + }, [firstName, lastName]) - return

{fullName}

; + return

{fullName}

} ``` @@ -1323,11 +1636,11 @@ function Form() { ```tsx function Form() { - const [firstName, setFirstName] = useState("First"); - const [lastName, setLastName] = useState("Last"); - const fullName = firstName + " " + lastName; + const [firstName, setFirstName] = useState('First') + const [lastName, setLastName] = useState('Last') + const fullName = firstName + ' ' + lastName - return

{fullName}

; + return

{fullName}

} ``` @@ -1343,14 +1656,14 @@ Don't subscribe to dynamic state (searchParams, localStorage) if you only read i ```tsx function ShareButton({ chatId }: { chatId: string }) { - const searchParams = useSearchParams(); + const searchParams = useSearchParams() const handleShare = () => { - const ref = searchParams.get("ref"); - shareChat(chatId, { ref }); - }; + const ref = searchParams.get('ref') + shareChat(chatId, { ref }) + } - return ; + return } ``` @@ -1359,12 +1672,12 @@ function ShareButton({ chatId }: { chatId: string }) { ```tsx function ShareButton({ chatId }: { chatId: string }) { const handleShare = () => { - const params = new URLSearchParams(window.location.search); - const ref = params.get("ref"); - shareChat(chatId, { ref }); - }; + const params = new URLSearchParams(window.location.search) + const ref = params.get('ref') + shareChat(chatId, { ref }) + } - return ; + return } ``` @@ -1381,10 +1694,10 @@ Calling `useMemo` and comparing hook dependencies may consume more resources tha ```tsx function Header({ user, notifications }: Props) { const isLoading = useMemo(() => { - return user.isLoading || notifications.isLoading; - }, [user.isLoading, notifications.isLoading]); + return user.isLoading || notifications.isLoading + }, [user.isLoading, notifications.isLoading]) - if (isLoading) return ; + if (isLoading) return // return some markup } ``` @@ -1393,62 +1706,142 @@ function Header({ user, notifications }: Props) { ```tsx function Header({ user, notifications }: Props) { - const isLoading = user.isLoading || notifications.isLoading; + const isLoading = user.isLoading || notifications.isLoading - if (isLoading) return ; + if (isLoading) return // return some markup } ``` -### 5.4 Extract Default Non-primitive Parameter Value from Memoized Component to Constant +### 5.4 Don't Define Components Inside Components -**Impact: MEDIUM (restores memoization by using a constant for default value)** +**Impact: HIGH (prevents remount on every render)** -When memoized component has a default value for some non-primitive optional parameter, such as an array, function, or object, calling the component without that parameter results in broken memoization. This is because new value instances are created on every rerender, and they do not pass strict equality comparison in `memo()`. +Defining a component inside another component creates a new component type on every render. React sees a different component each time and fully remounts it, destroying all state and DOM. -To address this issue, extract the default value into a constant. +A common reason developers do this is to access parent variables without passing props. Always pass props instead. -**Incorrect: `onClick` has different values on every rerender** +**Incorrect: remounts on every render** ```tsx -const UserAvatar = memo(function UserAvatar({ onClick = () => {} }: { onClick?: () => void }) { - // ... -}) +function UserProfile({ user, theme }) { + // Defined inside to access `theme` - BAD + const Avatar = () => ( + + ) -// Used without optional onClick - + // Defined inside to access `user` - BAD + const Stats = () => ( +
+ {user.followers} followers + {user.posts} posts +
+ ) + + return ( +
+ + +
+ ) +} ``` -**Correct: stable default value** +Every time `UserProfile` renders, `Avatar` and `Stats` are new component types. React unmounts the old instances and mounts new ones, losing any internal state, running effects again, and recreating DOM nodes. + +**Correct: pass props instead** ```tsx -const NOOP = () => {}; +function Avatar({ src, theme }: { src: string; theme: string }) { + return ( + + ) +} -const UserAvatar = memo(function UserAvatar({ onClick = NOOP }: { onClick?: () => void }) { - // ... -}) +function Stats({ followers, posts }: { followers: number; posts: number }) { + return ( +
+ {followers} followers + {posts} posts +
+ ) +} -// Used without optional onClick - +function UserProfile({ user, theme }) { + return ( +
+ + +
+ ) +} ``` -### 5.5 Extract to Memoized Components +**Symptoms of this bug:** -**Impact: MEDIUM (enables early returns)** +- Input fields lose focus on every keystroke -Extract expensive work into memoized components to enable early returns before computation. +- Animations restart unexpectedly -**Incorrect: computes avatar even when loading** +- `useEffect` cleanup/setup runs on every parent render + +- Scroll position resets inside the component + +### 5.5 Extract Default Non-primitive Parameter Value from Memoized Component to Constant + +**Impact: MEDIUM (restores memoization by using a constant for default value)** + +When memoized component has a default value for some non-primitive optional parameter, such as an array, function, or object, calling the component without that parameter results in broken memoization. This is because new value instances are created on every rerender, and they do not pass strict equality comparison in `memo()`. + +To address this issue, extract the default value into a constant. + +**Incorrect: `onClick` has different values on every rerender** + +```tsx +const UserAvatar = memo(function UserAvatar({ onClick = () => {} }: { onClick?: () => void }) { + // ... +}) + +// Used without optional onClick + +``` + +**Correct: stable default value** + +```tsx +const NOOP = () => {}; + +const UserAvatar = memo(function UserAvatar({ onClick = NOOP }: { onClick?: () => void }) { + // ... +}) + +// Used without optional onClick + +``` + +### 5.6 Extract to Memoized Components + +**Impact: MEDIUM (enables early returns)** + +Extract expensive work into memoized components to enable early returns before computation. + +**Incorrect: computes avatar even when loading** ```tsx function Profile({ user, loading }: Props) { const avatar = useMemo(() => { - const id = computeAvatarId(user); - return ; - }, [user]); + const id = computeAvatarId(user) + return + }, [user]) - if (loading) return ; - return
{avatar}
; + if (loading) return + return
{avatar}
} ``` @@ -1456,23 +1849,23 @@ function Profile({ user, loading }: Props) { ```tsx const UserAvatar = memo(function UserAvatar({ user }: { user: User }) { - const id = useMemo(() => computeAvatarId(user), [user]); - return ; -}); + const id = useMemo(() => computeAvatarId(user), [user]) + return +}) function Profile({ user, loading }: Props) { - if (loading) return ; + if (loading) return return (
- ); + ) } ``` **Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, manual memoization with `memo()` and `useMemo()` is not necessary. The compiler automatically optimizes re-renders. -### 5.6 Narrow Effect Dependencies +### 5.7 Narrow Effect Dependencies **Impact: LOW (minimizes effect re-runs)** @@ -1482,16 +1875,16 @@ Specify primitive dependencies instead of objects to minimize effect re-runs. ```tsx useEffect(() => { - console.log(user.id); -}, [user]); + console.log(user.id) +}, [user]) ``` **Correct: re-runs only when id changes** ```tsx useEffect(() => { - console.log(user.id); -}, [user.id]); + console.log(user.id) +}, [user.id]) ``` **For derived state, compute outside effect:** @@ -1500,20 +1893,20 @@ useEffect(() => { // Incorrect: runs on width=767, 766, 765... useEffect(() => { if (width < 768) { - enableMobileMode(); + enableMobileMode() } -}, [width]); +}, [width]) // Correct: runs only on boolean transition -const isMobile = width < 768; +const isMobile = width < 768 useEffect(() => { if (isMobile) { - enableMobileMode(); + enableMobileMode() } -}, [isMobile]); +}, [isMobile]) ``` -### 5.7 Put Interaction Logic in Event Handlers +### 5.8 Put Interaction Logic in Event Handlers **Impact: MEDIUM (avoids effect re-runs and duplicate side effects)** @@ -1523,17 +1916,17 @@ If a side effect is triggered by a specific user action (submit, click, drag), r ```tsx function Form() { - const [submitted, setSubmitted] = useState(false); - const theme = useContext(ThemeContext); + const [submitted, setSubmitted] = useState(false) + const theme = useContext(ThemeContext) useEffect(() => { if (submitted) { - post("/api/register"); - showToast("Registered", theme); + post('/api/register') + showToast('Registered', theme) } - }, [submitted, theme]); + }, [submitted, theme]) - return ; + return } ``` @@ -1541,20 +1934,80 @@ function Form() { ```tsx function Form() { - const theme = useContext(ThemeContext); + const theme = useContext(ThemeContext) function handleSubmit() { - post("/api/register"); - showToast("Registered", theme); + post('/api/register') + showToast('Registered', theme) } - return ; + return } ``` Reference: [https://react.dev/learn/removing-effect-dependencies#should-this-code-move-to-an-event-handler](https://react.dev/learn/removing-effect-dependencies#should-this-code-move-to-an-event-handler) -### 5.8 Subscribe to Derived State +### 5.9 Split Combined Hook Computations + +**Impact: MEDIUM (avoids recomputing independent steps)** + +When a hook contains multiple independent tasks with different dependencies, split them into separate hooks. A combined hook reruns all tasks when any dependency changes, even if some tasks don't use the changed value. + +**Incorrect: changing `sortOrder` recomputes filtering** + +```tsx +const sortedProducts = useMemo(() => { + const filtered = products.filter((p) => p.category === category) + const sorted = filtered.toSorted((a, b) => + sortOrder === "asc" ? a.price - b.price : b.price - a.price + ) + return sorted +}, [products, category, sortOrder]) +``` + +**Correct: filtering only recomputes when products or category change** + +```tsx +const filteredProducts = useMemo( + () => products.filter((p) => p.category === category), + [products, category] +) + +const sortedProducts = useMemo( + () => + filteredProducts.toSorted((a, b) => + sortOrder === "asc" ? a.price - b.price : b.price - a.price + ), + [filteredProducts, sortOrder] +) +``` + +This pattern also applies to `useEffect` when combining unrelated side effects: + +**Incorrect: both effects run when either dependency changes** + +```tsx +useEffect(() => { + analytics.trackPageView(pathname) + document.title = `${pageTitle} | My App` +}, [pathname, pageTitle]) +``` + +**Correct: effects run independently** + +```tsx +useEffect(() => { + analytics.trackPageView(pathname) +}, [pathname]) + +useEffect(() => { + document.title = `${pageTitle} | My App` +}, [pageTitle]) +``` + +**Note:** If your project has [React Compiler](https://react.dev/learn/react-compiler) enabled, it automatically optimizes dependency tracking and may handle some of these cases for you. + +### 5.10 Subscribe to Derived State **Impact: MEDIUM (reduces re-render frequency)** @@ -1564,9 +2017,9 @@ Subscribe to derived boolean state instead of continuous values to reduce re-ren ```tsx function Sidebar() { - const width = useWindowWidth(); // updates continuously - const isMobile = width < 768; - return