From f56ffe47c36831d1878ec36cb9bc4294632a4244 Mon Sep 17 00:00:00 2001 From: Sergiy Dybskiy Date: Thu, 29 Jan 2026 17:52:17 -0500 Subject: [PATCH] Add Claude Code AI setup with skills and documentation - Add .agents/skills with TanStack Start, Better Auth, React Flow, Vercel React, and Wrangler best practices - Add .claude/skills symlinks for Claude Code integration - Update CLAUDE.md with comprehensive project documentation including commands, architecture, and design system Co-Authored-By: Claude Opus 4.5 --- .../better-auth-best-practices/SKILL.md | 166 + .agents/skills/react-flow/SKILL.md | 442 +++ .../react-flow/references/custom-edges.md | 408 +++ .../react-flow/references/custom-nodes.md | 285 ++ .../skills/react-flow/references/events.md | 629 ++++ .../skills/react-flow/references/viewport.md | 434 +++ .../tanstack-start-best-practices/SKILL.md | 109 + .../rules/api-routes.md | 238 ++ .../rules/auth-route-protection.md | 192 ++ .../rules/auth-session-management.md | 191 ++ .../rules/deploy-adapters.md | 201 ++ .../rules/env-functions.md | 211 ++ .../rules/err-server-errors.md | 187 ++ .../rules/file-separation.md | 152 + .../rules/mw-request-middleware.md | 166 + .../rules/sf-create-server-fn.md | 146 + .../rules/sf-input-validation.md | 158 + .../rules/ssr-hydration-safety.md | 187 ++ .../rules/ssr-prerender.md | 199 ++ .../rules/ssr-streaming.md | 201 ++ .../vercel-react-best-practices/AGENTS.md | 2934 +++++++++++++++++ .../vercel-react-best-practices/SKILL.md | 136 + .../rules/advanced-event-handler-refs.md | 55 + .../rules/advanced-init-once.md | 42 + .../rules/advanced-use-latest.md | 39 + .../rules/async-api-routes.md | 38 + .../rules/async-defer-await.md | 80 + .../rules/async-dependencies.md | 51 + .../rules/async-parallel.md | 28 + .../rules/async-suspense-boundaries.md | 99 + .../rules/bundle-barrel-imports.md | 59 + .../rules/bundle-conditional.md | 31 + .../rules/bundle-defer-third-party.md | 49 + .../rules/bundle-dynamic-imports.md | 35 + .../rules/bundle-preload.md | 50 + .../rules/client-event-listeners.md | 74 + .../rules/client-localstorage-schema.md | 71 + .../rules/client-passive-event-listeners.md | 48 + .../rules/client-swr-dedup.md | 56 + .../rules/js-batch-dom-css.md | 107 + .../rules/js-cache-function-results.md | 80 + .../rules/js-cache-property-access.md | 28 + .../rules/js-cache-storage.md | 70 + .../rules/js-combine-iterations.md | 32 + .../rules/js-early-exit.md | 50 + .../rules/js-hoist-regexp.md | 45 + .../rules/js-index-maps.md | 37 + .../rules/js-length-check-first.md | 49 + .../rules/js-min-max-loop.md | 82 + .../rules/js-set-map-lookups.md | 24 + .../rules/js-tosorted-immutable.md | 57 + .../rules/rendering-activity.md | 26 + .../rules/rendering-animate-svg-wrapper.md | 47 + .../rules/rendering-conditional-render.md | 40 + .../rules/rendering-content-visibility.md | 38 + .../rules/rendering-hoist-jsx.md | 46 + .../rules/rendering-hydration-no-flicker.md | 82 + .../rendering-hydration-suppress-warning.md | 30 + .../rules/rendering-svg-precision.md | 28 + .../rules/rendering-usetransition-loading.md | 75 + .../rules/rerender-defer-reads.md | 39 + .../rules/rerender-dependencies.md | 45 + .../rules/rerender-derived-state-no-effect.md | 40 + .../rules/rerender-derived-state.md | 29 + .../rules/rerender-functional-setstate.md | 74 + .../rules/rerender-lazy-state-init.md | 58 + .../rules/rerender-memo-with-default-value.md | 38 + .../rules/rerender-memo.md | 44 + .../rules/rerender-move-effect-to-event.md | 45 + .../rerender-simple-expression-in-memo.md | 35 + .../rules/rerender-transitions.md | 40 + .../rerender-use-ref-transient-values.md | 73 + .../rules/server-after-nonblocking.md | 73 + .../rules/server-auth-actions.md | 96 + .../rules/server-cache-lru.md | 41 + .../rules/server-cache-react.md | 76 + .../rules/server-dedup-props.md | 65 + .../rules/server-parallel-fetching.md | 83 + .../rules/server-serialization.md | 38 + .agents/skills/wrangler/SKILL.md | 887 +++++ .claude/skills/better-auth-best-practices | 1 + .claude/skills/react-flow | 1 + .claude/skills/tanstack-start-best-practices | 1 + .claude/skills/vercel-react-best-practices | 1 + .claude/skills/wrangler | 1 + .gitignore | 1 + CLAUDE.md | 197 +- 87 files changed, 12054 insertions(+), 18 deletions(-) create mode 100644 .agents/skills/better-auth-best-practices/SKILL.md create mode 100644 .agents/skills/react-flow/SKILL.md create mode 100644 .agents/skills/react-flow/references/custom-edges.md create mode 100644 .agents/skills/react-flow/references/custom-nodes.md create mode 100644 .agents/skills/react-flow/references/events.md create mode 100644 .agents/skills/react-flow/references/viewport.md create mode 100644 .agents/skills/tanstack-start-best-practices/SKILL.md create mode 100644 .agents/skills/tanstack-start-best-practices/rules/api-routes.md create mode 100644 .agents/skills/tanstack-start-best-practices/rules/auth-route-protection.md create mode 100644 .agents/skills/tanstack-start-best-practices/rules/auth-session-management.md create mode 100644 .agents/skills/tanstack-start-best-practices/rules/deploy-adapters.md create mode 100644 .agents/skills/tanstack-start-best-practices/rules/env-functions.md create mode 100644 .agents/skills/tanstack-start-best-practices/rules/err-server-errors.md create mode 100644 .agents/skills/tanstack-start-best-practices/rules/file-separation.md create mode 100644 .agents/skills/tanstack-start-best-practices/rules/mw-request-middleware.md create mode 100644 .agents/skills/tanstack-start-best-practices/rules/sf-create-server-fn.md create mode 100644 .agents/skills/tanstack-start-best-practices/rules/sf-input-validation.md create mode 100644 .agents/skills/tanstack-start-best-practices/rules/ssr-hydration-safety.md create mode 100644 .agents/skills/tanstack-start-best-practices/rules/ssr-prerender.md create mode 100644 .agents/skills/tanstack-start-best-practices/rules/ssr-streaming.md create mode 100644 .agents/skills/vercel-react-best-practices/AGENTS.md create mode 100644 .agents/skills/vercel-react-best-practices/SKILL.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/advanced-event-handler-refs.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/advanced-init-once.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/advanced-use-latest.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/async-api-routes.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/async-defer-await.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/async-dependencies.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/async-parallel.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/async-suspense-boundaries.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/bundle-barrel-imports.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/bundle-conditional.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/bundle-defer-third-party.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/bundle-dynamic-imports.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/bundle-preload.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/client-event-listeners.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/client-localstorage-schema.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/client-passive-event-listeners.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/client-swr-dedup.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/js-batch-dom-css.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/js-cache-function-results.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/js-cache-property-access.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/js-cache-storage.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/js-combine-iterations.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/js-early-exit.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/js-hoist-regexp.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/js-index-maps.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/js-length-check-first.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/js-min-max-loop.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/js-set-map-lookups.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/js-tosorted-immutable.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/rendering-activity.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/rendering-animate-svg-wrapper.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/rendering-conditional-render.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/rendering-content-visibility.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/rendering-hoist-jsx.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/rendering-hydration-no-flicker.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/rendering-hydration-suppress-warning.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/rendering-svg-precision.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/rendering-usetransition-loading.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/rerender-defer-reads.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/rerender-dependencies.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/rerender-derived-state-no-effect.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/rerender-derived-state.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/rerender-functional-setstate.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/rerender-lazy-state-init.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/rerender-memo-with-default-value.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/rerender-memo.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/rerender-move-effect-to-event.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/rerender-simple-expression-in-memo.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/rerender-transitions.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/rerender-use-ref-transient-values.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/server-after-nonblocking.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/server-auth-actions.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/server-cache-lru.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/server-cache-react.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/server-dedup-props.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/server-parallel-fetching.md create mode 100644 .agents/skills/vercel-react-best-practices/rules/server-serialization.md create mode 100644 .agents/skills/wrangler/SKILL.md create mode 120000 .claude/skills/better-auth-best-practices create mode 120000 .claude/skills/react-flow create mode 120000 .claude/skills/tanstack-start-best-practices create mode 120000 .claude/skills/vercel-react-best-practices create mode 120000 .claude/skills/wrangler diff --git a/.agents/skills/better-auth-best-practices/SKILL.md b/.agents/skills/better-auth-best-practices/SKILL.md new file mode 100644 index 0000000..3458e07 --- /dev/null +++ b/.agents/skills/better-auth-best-practices/SKILL.md @@ -0,0 +1,166 @@ +--- +name: better-auth-best-practices +description: Skill for integrating Better Auth - the comprehensive TypeScript authentication framework. +--- + +# Better Auth Integration Guide + +**Always consult [better-auth.com/docs](https://better-auth.com/docs) for code examples and latest API.** + +Better Auth is a TypeScript-first, framework-agnostic auth framework supporting email/password, OAuth, magic links, passkeys, and more via plugins. + +--- + +## Quick Reference + +### Environment Variables +- `BETTER_AUTH_SECRET` - Encryption secret (min 32 chars). Generate: `openssl rand -base64 32` +- `BETTER_AUTH_URL` - Base URL (e.g., `https://example.com`) + +Only define `baseURL`/`secret` in config if env vars are NOT set. + +### File Location +CLI looks for `auth.ts` in: `./`, `./lib`, `./utils`, or under `./src`. Use `--config` for custom path. + +### CLI Commands +- `npx @better-auth/cli@latest migrate` - Apply schema (built-in adapter) +- `npx @better-auth/cli@latest generate` - Generate schema for Prisma/Drizzle +- `npx @better-auth/cli mcp --cursor` - Add MCP to AI tools + +**Re-run after adding/changing plugins.** + +--- + +## Core Config Options + +| Option | Notes | +|--------|-------| +| `appName` | Optional display name | +| `baseURL` | Only if `BETTER_AUTH_URL` not set | +| `basePath` | Default `/api/auth`. Set `/` for root. | +| `secret` | Only if `BETTER_AUTH_SECRET` not set | +| `database` | Required for most features. See adapters docs. | +| `secondaryStorage` | Redis/KV for sessions & rate limits | +| `emailAndPassword` | `{ enabled: true }` to activate | +| `socialProviders` | `{ google: { clientId, clientSecret }, ... }` | +| `plugins` | Array of plugins | +| `trustedOrigins` | CSRF whitelist | + +--- + +## Database + +**Direct connections:** Pass `pg.Pool`, `mysql2` pool, `better-sqlite3`, or `bun:sqlite` instance. + +**ORM adapters:** Import from `better-auth/adapters/drizzle`, `better-auth/adapters/prisma`, `better-auth/adapters/mongodb`. + +**Critical:** Better Auth uses adapter model names, NOT underlying table names. If Prisma model is `User` mapping to table `users`, use `modelName: "user"` (Prisma reference), not `"users"`. + +--- + +## Session Management + +**Storage priority:** +1. If `secondaryStorage` defined → sessions go there (not DB) +2. Set `session.storeSessionInDatabase: true` to also persist to DB +3. No database + `cookieCache` → fully stateless mode + +**Cookie cache strategies:** +- `compact` (default) - Base64url + HMAC. Smallest. +- `jwt` - Standard JWT. Readable but signed. +- `jwe` - Encrypted. Maximum security. + +**Key options:** `session.expiresIn` (default 7 days), `session.updateAge` (refresh interval), `session.cookieCache.maxAge`, `session.cookieCache.version` (change to invalidate all sessions). + +--- + +## User & Account Config + +**User:** `user.modelName`, `user.fields` (column mapping), `user.additionalFields`, `user.changeEmail.enabled` (disabled by default), `user.deleteUser.enabled` (disabled by default). + +**Account:** `account.modelName`, `account.accountLinking.enabled`, `account.storeAccountCookie` (for stateless OAuth). + +**Required for registration:** `email` and `name` fields. + +--- + +## Email Flows + +- `emailVerification.sendVerificationEmail` - Must be defined for verification to work +- `emailVerification.sendOnSignUp` / `sendOnSignIn` - Auto-send triggers +- `emailAndPassword.sendResetPassword` - Password reset email handler + +--- + +## Security + +**In `advanced`:** +- `useSecureCookies` - Force HTTPS cookies +- `disableCSRFCheck` - ⚠️ Security risk +- `disableOriginCheck` - ⚠️ Security risk +- `crossSubDomainCookies.enabled` - Share cookies across subdomains +- `ipAddress.ipAddressHeaders` - Custom IP headers for proxies +- `database.generateId` - Custom ID generation or `"serial"`/`"uuid"`/`false` + +**Rate limiting:** `rateLimit.enabled`, `rateLimit.window`, `rateLimit.max`, `rateLimit.storage` ("memory" | "database" | "secondary-storage"). + +--- + +## Hooks + +**Endpoint hooks:** `hooks.before` / `hooks.after` - Array of `{ matcher, handler }`. Use `createAuthMiddleware`. Access `ctx.path`, `ctx.context.returned` (after), `ctx.context.session`. + +**Database hooks:** `databaseHooks.user.create.before/after`, same for `session`, `account`. Useful for adding default values or post-creation actions. + +**Hook context (`ctx.context`):** `session`, `secret`, `authCookies`, `password.hash()`/`verify()`, `adapter`, `internalAdapter`, `generateId()`, `tables`, `baseURL`. + +--- + +## Plugins + +**Import from dedicated paths for tree-shaking:** +``` +import { twoFactor } from "better-auth/plugins/two-factor" +``` +NOT `from "better-auth/plugins"`. + +**Popular plugins:** `twoFactor`, `organization`, `passkey`, `magicLink`, `emailOtp`, `username`, `phoneNumber`, `admin`, `apiKey`, `bearer`, `jwt`, `multiSession`, `sso`, `oauthProvider`, `oidcProvider`, `openAPI`, `genericOAuth`. + +Client plugins go in `createAuthClient({ plugins: [...] })`. + +--- + +## Client + +Import from: `better-auth/client` (vanilla), `better-auth/react`, `better-auth/vue`, `better-auth/svelte`, `better-auth/solid`. + +Key methods: `signUp.email()`, `signIn.email()`, `signIn.social()`, `signOut()`, `useSession()`, `getSession()`, `revokeSession()`, `revokeSessions()`. + +--- + +## Type Safety + +Infer types: `typeof auth.$Infer.Session`, `typeof auth.$Infer.Session.user`. + +For separate client/server projects: `createAuthClient()`. + +--- + +## Common Gotchas + +1. **Model vs table name** - Config uses ORM model name, not DB table name +2. **Plugin schema** - Re-run CLI after adding plugins +3. **Secondary storage** - Sessions go there by default, not DB +4. **Cookie cache** - Custom session fields NOT cached, always re-fetched +5. **Stateless mode** - No DB = session in cookie only, logout on cache expiry +6. **Change email flow** - Sends to current email first, then new email + +--- + +## Resources + +- [Docs](https://better-auth.com/docs) +- [Options Reference](https://better-auth.com/docs/reference/options) +- [LLMs.txt](https://better-auth.com/llms.txt) +- [GitHub](https://github.com/better-auth/better-auth) +- [Init Options Source](https://github.com/better-auth/better-auth/blob/main/packages/core/src/types/init-options.ts) \ No newline at end of file diff --git a/.agents/skills/react-flow/SKILL.md b/.agents/skills/react-flow/SKILL.md new file mode 100644 index 0000000..bcdead5 --- /dev/null +++ b/.agents/skills/react-flow/SKILL.md @@ -0,0 +1,442 @@ +--- +name: react-flow +description: React Flow (@xyflow/react) for workflow visualization with custom nodes and edges. Use when building graph visualizations, creating custom workflow nodes, implementing edge labels, or controlling viewport. Triggers on ReactFlow, @xyflow/react, Handle, NodeProps, EdgeProps, useReactFlow, fitView. +--- + +# React Flow + +React Flow (@xyflow/react) is a library for building node-based graphs, workflow editors, and interactive diagrams. It provides a highly customizable framework for creating visual programming interfaces, process flows, and network visualizations. + +## Quick Start + +### Installation + +```bash +pnpm add @xyflow/react +``` + +### Basic Setup + +```typescript +import { ReactFlow, Node, Edge, Background, Controls, MiniMap } from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; + +const initialNodes: Node[] = [ + { + id: '1', + type: 'input', + data: { label: 'Input Node' }, + position: { x: 250, y: 5 }, + }, + { + id: '2', + data: { label: 'Default Node' }, + position: { x: 100, y: 100 }, + }, + { + id: '3', + type: 'output', + data: { label: 'Output Node' }, + position: { x: 400, y: 100 }, + }, +]; + +const initialEdges: Edge[] = [ + { id: 'e1-2', source: '1', target: '2', animated: true }, + { id: 'e2-3', source: '2', target: '3' }, +]; + +function Flow() { + return ( +
+ + + + + +
+ ); +} + +export default Flow; +``` + +## Core Concepts + +### Nodes + +Nodes are the building blocks of the graph. Each node has: +- `id`: Unique identifier +- `type`: Node type (built-in or custom) +- `position`: { x, y } coordinates +- `data`: Custom data object + +```typescript +import { Node } from '@xyflow/react'; + +const node: Node = { + id: 'node-1', + type: 'default', + position: { x: 100, y: 100 }, + data: { label: 'Node Label' }, + style: { background: '#D6D5E6' }, + className: 'custom-node', +}; +``` + +Built-in node types: +- `default`: Standard node +- `input`: No target handles +- `output`: No source handles +- `group`: Container for other nodes + +### Edges + +Edges connect nodes. Each edge requires: +- `id`: Unique identifier +- `source`: Source node ID +- `target`: Target node ID + +```typescript +import { Edge } from '@xyflow/react'; + +const edge: Edge = { + id: 'e1-2', + source: '1', + target: '2', + type: 'smoothstep', + animated: true, + label: 'Edge Label', + style: { stroke: '#fff', strokeWidth: 2 }, +}; +``` + +Built-in edge types: +- `default`: Bezier curve +- `straight`: Straight line +- `step`: Orthogonal with sharp corners +- `smoothstep`: Orthogonal with rounded corners + +### Handles + +Handles are connection points on nodes. Use `Position` enum for placement: + +```typescript +import { Handle, Position } from '@xyflow/react'; + + + +``` + +Available positions: `Position.Top`, `Position.Right`, `Position.Bottom`, `Position.Left` + +## State Management + +### Controlled Flow + +Use state hooks for full control: + +```typescript +import { useNodesState, useEdgesState, addEdge, OnConnect } from '@xyflow/react'; +import { useCallback } from 'react'; + +function ControlledFlow() { + const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); + + const onConnect: OnConnect = useCallback( + (connection) => setEdges((eds) => addEdge(connection, eds)), + [setEdges] + ); + + return ( + + ); +} +``` + +### useReactFlow Hook + +Access the React Flow instance for programmatic control: + +```typescript +import { useReactFlow } from '@xyflow/react'; + +function FlowControls() { + const { + getNodes, + getEdges, + setNodes, + setEdges, + addNodes, + addEdges, + deleteElements, + fitView, + zoomIn, + zoomOut, + getNode, + getEdge, + updateNode, + updateEdge, + } = useReactFlow(); + + return ( + + ); +} +``` + +## Custom Nodes + +Define custom nodes using `NodeProps` with typed data: + +```typescript +import { NodeProps, Node, Handle, Position } from '@xyflow/react'; + +export type CustomNode = Node<{ label: string; status: 'active' | 'inactive' }, 'custom'>; + +function CustomNodeComponent({ data, selected }: NodeProps) { + return ( +
+ +
{data.label}
+ +
+ ); +} +``` + +Register with `nodeTypes`: + +```typescript +const nodeTypes: NodeTypes = { custom: CustomNodeComponent }; + +``` + +### Key Patterns + +- **Multiple Handles**: Use `id` prop and `style` for positioning +- **Dynamic Handles**: Call `useUpdateNodeInternals([nodeId])` after adding/removing handles +- **Interactive Elements**: Add `className="nodrag"` to prevent dragging on inputs/buttons + +See [Custom Nodes Reference](./references/custom-nodes.md) for detailed patterns including styling, aviation map pins, and dynamic handles. + +## Custom Edges + +Define custom edges using `EdgeProps` and path utilities: + +```typescript +import { BaseEdge, EdgeProps, getBezierPath } from '@xyflow/react'; + +export type CustomEdge = Edge<{ status: 'normal' | 'error' }, 'custom'>; + +function CustomEdgeComponent(props: EdgeProps) { + const [edgePath] = getBezierPath(props); + + return ( + + ); +} +``` + +### Path Utilities + +- `getBezierPath()` - Smooth curves +- `getStraightPath()` - Straight lines +- `getSmoothStepPath()` - Orthogonal with rounded corners +- `getSmoothStepPath({ borderRadius: 0 })` - Orthogonal with sharp corners (step edge) + +All return `[path, labelX, labelY, offsetX, offsetY]`. + +### Interactive Labels + +Use `EdgeLabelRenderer` for HTML-based labels with pointer events: + +```typescript +import { EdgeLabelRenderer, BaseEdge, getBezierPath } from '@xyflow/react'; + +function ButtonEdge(props: EdgeProps) { + const [edgePath, labelX, labelY] = getBezierPath(props); + return ( + <> + + +
+ +
+
+ + ); +} +``` + +See [Custom Edges Reference](./references/custom-edges.md) for animated edges, time labels, and SVG text patterns. + +## Viewport Control + +Use `useReactFlow()` hook for programmatic viewport control: + +```typescript +import { useReactFlow } from '@xyflow/react'; + +function ViewportControls() { + const { fitView, zoomIn, zoomOut, setCenter, screenToFlowPosition } = useReactFlow(); + + // Fit all nodes in view + const handleFitView = () => fitView({ padding: 0.2, duration: 400 }); + + // Zoom controls + const handleZoomIn = () => zoomIn({ duration: 300 }); + const handleZoomOut = () => zoomOut({ duration: 300 }); + + // Center on specific coordinates + const handleCenter = () => setCenter(250, 250, { zoom: 1.5, duration: 500 }); + + // Convert screen coordinates to flow coordinates + const addNodeAtClick = (event: React.MouseEvent) => { + const position = screenToFlowPosition({ x: event.clientX, y: event.clientY }); + // Use position to add node + }; + + return null; +} +``` + +See [Viewport Reference](./references/viewport.md) for save/restore state, controlled viewport, and coordinate transformations. + +## Events + +React Flow provides comprehensive event handling: + +### Node Events + +```typescript +import { NodeMouseHandler, OnNodeDrag } from '@xyflow/react'; + +const onNodeClick: NodeMouseHandler = (event, node) => { + console.log('Node clicked:', node.id); +}; + +const onNodeDrag: OnNodeDrag = (event, node, nodes) => { + console.log('Dragging:', node.id); +}; + + +``` + +### Edge and Connection Events + +```typescript +import { EdgeMouseHandler, OnConnect } from '@xyflow/react'; + +const onEdgeClick: EdgeMouseHandler = (event, edge) => console.log('Edge:', edge.id); +const onConnect: OnConnect = (connection) => console.log('Connected:', connection); + + +``` + +### Selection and Viewport Events + +```typescript +import { useOnSelectionChange, useOnViewportChange } from '@xyflow/react'; + +useOnSelectionChange({ + onChange: ({ nodes, edges }) => console.log('Selected:', nodes.length, edges.length), +}); + +useOnViewportChange({ + onChange: (viewport) => console.log('Viewport:', viewport.zoom), +}); +``` + +See [Events Reference](./references/events.md) for complete event catalog including validation, deletion, and error handling. + +## Common Patterns + +### Preventing Drag/Pan + +```typescript + + +``` + +### Connection Validation + +```typescript +const isValidConnection = (connection: Connection) => { + return connection.source !== connection.target; // Prevent self-connections +}; + + +``` + +### Adding Nodes on Click + +```typescript +const { screenToFlowPosition, setNodes } = useReactFlow(); + +const onPaneClick = (event: React.MouseEvent) => { + const position = screenToFlowPosition({ x: event.clientX, y: event.clientY }); + setNodes(nodes => [...nodes, { id: `node-${Date.now()}`, position, data: { label: 'New' } }]); +}; +``` + +### Updating Node Data + +```typescript +const { updateNodeData } = useReactFlow(); +updateNodeData('node-1', { label: 'Updated' }); +updateNodeData('node-1', (node) => ({ ...node.data, count: node.data.count + 1 })); +``` + +## Provider Pattern + +Wrap the app with `ReactFlowProvider` when using `useReactFlow()` outside the flow: + +```typescript +import { ReactFlow, ReactFlowProvider, useReactFlow } from '@xyflow/react'; + +function Controls() { + const { fitView } = useReactFlow(); // Must be inside provider + return ; +} + +function App() { + return ( + + + + + ); +} +``` + +## Reference Files + +For detailed implementation patterns, see: + +- [Custom Nodes](./references/custom-nodes.md) - NodeProps typing, Handle component, dynamic handles, styling patterns +- [Custom Edges](./references/custom-edges.md) - EdgeProps typing, path utilities, EdgeLabelRenderer, animated edges +- [Viewport](./references/viewport.md) - useReactFlow methods, fitView options, coordinate conversion +- [Events](./references/events.md) - Node/edge/connection events, selection handling, viewport changes diff --git a/.agents/skills/react-flow/references/custom-edges.md b/.agents/skills/react-flow/references/custom-edges.md new file mode 100644 index 0000000..0188024 --- /dev/null +++ b/.agents/skills/react-flow/references/custom-edges.md @@ -0,0 +1,408 @@ +# Custom Edges + +Custom edges in React Flow use the `EdgeProps` typing pattern and path utility functions to render connections between nodes. + +## Table of Contents + +- [Edge Type Definition](#edge-type-definition) +- [EdgeProps Structure](#edgeprops-structure) +- [Path Utility Functions](#path-utility-functions) +- [BaseEdge Component](#baseedge-component) +- [EdgeLabelRenderer for Interactive Labels](#edgelabelrenderer-for-interactive-labels) +- [Animated Edges](#animated-edges) +- [SVG Text Labels](#svg-text-labels) +- [EdgeText Component](#edgetext-component) +- [Time Label Edge Example](#time-label-edge-example) +- [Edge Registration](#edge-registration) +- [Default Edge Options](#default-edge-options) + +## Edge Type Definition + +Define custom edge types with typed data: + +```typescript +import { Edge, EdgeProps } from '@xyflow/react'; + +// Define the custom edge type +export type TimeLabelEdge = Edge<{ time: string; label: string }, 'timeLabel'>; + +// Component receives EdgeProps +export default function TimeLabelEdge(props: EdgeProps) { + // Edge implementation +} +``` + +## EdgeProps Structure + +The `EdgeProps` type includes these key properties: + +```typescript +type EdgeProps = { + id: string; + type?: string; + source: string; + target: string; + sourceX: number; + sourceY: number; + targetX: number; + targetY: number; + sourcePosition: Position; + targetPosition: Position; + data?: T['data']; + selected?: boolean; + animated?: boolean; + style?: CSSProperties; + markerStart?: string; + markerEnd?: string; + sourceHandleId?: string | null; + targetHandleId?: string | null; + label?: ReactNode; + labelStyle?: CSSProperties; + labelShowBg?: boolean; + labelBgStyle?: CSSProperties; + labelBgPadding?: [number, number]; + labelBgBorderRadius?: number; + interactionWidth?: number; + pathOptions?: any; +}; +``` + +## Path Utility Functions + +React Flow provides several path generators: + +### getBezierPath + +Creates smooth curved paths: + +```typescript +import { FC } from 'react'; +import { BaseEdge, EdgeProps, getBezierPath } from '@xyflow/react'; + +const CustomEdge: FC = ({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + data, +}) => { + const [edgePath, labelX, labelY] = getBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + curvature: 0.25, // Optional: control curve amount (default 0.25) + }); + + return ; +}; +``` + +### getStraightPath + +Creates direct straight lines: + +```typescript +import { getStraightPath } from '@xyflow/react'; + +const [edgePath, labelX, labelY] = getStraightPath({ + sourceX, + sourceY, + targetX, + targetY, +}); +``` + +### getSmoothStepPath + +Creates orthogonal paths with smooth corners: + +```typescript +import { getSmoothStepPath } from '@xyflow/react'; + +const [edgePath, labelX, labelY] = getSmoothStepPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + borderRadius: 8, // Optional: corner radius + offset: 20, // Optional: offset from node +}); +``` + +### getSmoothStepPath with borderRadius: 0 (Step Edge) + +For orthogonal paths with sharp corners, use `getSmoothStepPath` with `borderRadius: 0`: + +```typescript +import { getSmoothStepPath } from '@xyflow/react'; + +const [edgePath, labelX, labelY] = getSmoothStepPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + borderRadius: 0, // Sharp corners (step edge) + offset: 20, // Optional: offset from node +}); +``` + +## BaseEdge Component + +The `BaseEdge` component renders the path with proper styling: + +```typescript +import { BaseEdge, EdgeProps, getBezierPath } from '@xyflow/react'; + +function CustomEdge(props: EdgeProps) { + const [edgePath] = getBezierPath(props); + + return ( + + ); +} +``` + +## EdgeLabelRenderer for Interactive Labels + +Use `EdgeLabelRenderer` to render interactive HTML labels instead of SVG text: + +```typescript +import { getBezierPath, EdgeLabelRenderer, BaseEdge, EdgeProps } from '@xyflow/react'; + +function CustomEdge({ id, data, ...props }: EdgeProps) { + const [edgePath, labelX, labelY] = getBezierPath(props); + + return ( + <> + + +
+ +
+
+ + ); +} +``` + +## Animated Edges + +### Dash Animation + +Animate the stroke dash pattern: + +```typescript +const animatedEdgeStyle = { + strokeDasharray: '5 5', + animation: 'dashdraw 0.5s linear infinite', +}; + +// CSS +// @keyframes dashdraw { +// to { +// stroke-dashoffset: -10; +// } +// } + +function AnimatedEdge(props: EdgeProps) { + const [edgePath] = getBezierPath(props); + return ; +} +``` + +### Moving Circle Along Path + +```typescript +import { BaseEdge, EdgeProps, getBezierPath } from '@xyflow/react'; + +function MovingCircleEdge(props: EdgeProps) { + const [edgePath] = getBezierPath(props); + + return ( + <> + + + + + + ); +} +``` + +## SVG Text Labels + +For simple text labels along the path: + +```typescript +import { BaseEdge, EdgeProps, getBezierPath } from '@xyflow/react'; + +function TextLabelEdge({ id, data, ...props }: EdgeProps) { + const [edgePath] = getBezierPath(props); + + return ( + <> + + + + {data?.text || ''} + + + + ); +} +``` + +## EdgeText Component + +For positioned text with background: + +```typescript +import { BaseEdge, EdgeText, EdgeProps, getSmoothStepPath } from '@xyflow/react'; + +function LabeledEdge({ id, data, ...props }: EdgeProps) { + const [edgePath, labelX, labelY] = getSmoothStepPath(props); + + return ( + <> + + console.log(data)} + /> + + ); +} +``` + +## Time Label Edge Example + +Custom edge displaying time/duration labels: + +```typescript +import { EdgeProps, getBezierPath, EdgeLabelRenderer, BaseEdge } from '@xyflow/react'; + +type TimeLabelData = { + duration: string; + status: 'normal' | 'delayed' | 'critical'; +}; + +export type TimeLabelEdge = Edge; + +function TimeLabelEdge({ id, data, selected, ...props }: EdgeProps) { + const [edgePath, labelX, labelY] = getBezierPath(props); + + const statusColors = { + normal: 'bg-green-100 text-green-800', + delayed: 'bg-yellow-100 text-yellow-800', + critical: 'bg-red-100 text-red-800', + }; + + return ( + <> + + +
+
+ {data?.duration || '0m'} +
+
+
+ + ); +} +``` + +## Edge Registration + +Register custom edges in the `edgeTypes` prop: + +```typescript +import { ReactFlow, EdgeTypes } from '@xyflow/react'; +import TimeLabelEdge from './TimeLabelEdge'; +import AnimatedEdge from './AnimatedEdge'; + +const edgeTypes: EdgeTypes = { + timeLabel: TimeLabelEdge, + animated: AnimatedEdge, +}; + +function Flow() { + return ( + + ); +} +``` + +## Default Edge Options + +Set default properties for all edges: + +```typescript +import { DefaultEdgeOptions } from '@xyflow/react'; + +const defaultEdgeOptions: DefaultEdgeOptions = { + animated: true, + type: 'smoothstep', + style: { stroke: '#fff', strokeWidth: 2 }, +}; + + +``` diff --git a/.agents/skills/react-flow/references/custom-nodes.md b/.agents/skills/react-flow/references/custom-nodes.md new file mode 100644 index 0000000..8691484 --- /dev/null +++ b/.agents/skills/react-flow/references/custom-nodes.md @@ -0,0 +1,285 @@ +# Custom Nodes + +React Flow custom nodes use the `NodeProps` typing pattern where `T` is the specific node type with custom data. + +## Table of Contents + +- [Node Type Definition](#node-type-definition) +- [Handle Component](#handle-component) +- [Multiple Handles](#multiple-handles) +- [Dynamic Handles with useUpdateNodeInternals](#dynamic-handles-with-useupdatenodeinternals) +- [Styling Nodes](#styling-nodes) +- [Aviation Map Pin Node Example](#aviation-map-pin-node-example) +- [Preventing Drag and Pan](#preventing-drag-and-pan) +- [Node Registration](#node-registration) + +## Node Type Definition + +Define custom nodes with typed data and specify the node type string: + +```typescript +import { Node, NodeProps } from '@xyflow/react'; + +// Define the custom node type +export type CounterNode = Node<{ initialCount?: number }, 'counter'>; + +// Component receives NodeProps +export default function CounterNode(props: NodeProps) { + const [count, setCount] = useState(props.data?.initialCount ?? 0); + + return ( +
+

Count: {count}

+ +
+ ); +} +``` + +## Handle Component + +The `Handle` component defines connection points on nodes. Use `type="target"` for incoming connections and `type="source"` for outgoing connections. + +```typescript +import { Handle, Position } from '@xyflow/react'; + +function CustomNode({ data }) { + return ( + <> + +
{data.label}
+ + + ); +} +``` + +### Multiple Handles + +Use the `id` prop to create multiple handles on a single node: + +```typescript +import { Handle, Position, CSSProperties } from '@xyflow/react'; + +const sourceHandleStyleA: CSSProperties = { top: 10 }; +const sourceHandleStyleB: CSSProperties = { bottom: 10, top: 'auto' }; + +function MultiHandleNode({ data, isConnectable }: NodeProps) { + return ( + <> + +
{data.label}
+ + {/* Multiple source handles with IDs */} + + + + ); +} +``` + +## Dynamic Handles with useUpdateNodeInternals + +When adding or removing handles dynamically, use `useUpdateNodeInternals()` to notify React Flow: + +```typescript +import { useState, useMemo } from 'react'; +import { Handle, Position, useUpdateNodeInternals, NodeProps } from '@xyflow/react'; + +function DynamicHandleNode({ id }: NodeProps) { + const [handleCount, setHandleCount] = useState(1); + const updateNodeInternals = useUpdateNodeInternals(); + + const handles = useMemo( + () => + Array.from({ length: handleCount }, (x, i) => { + const handleId = `handle-${i}`; + return ( + + ); + }), + [handleCount] + ); + + return ( +
+ +
output handle count: {handleCount}
+ + {handles} +
+ ); +} +``` + +## Styling Nodes + +### CSS Classes + +Apply styles with `className` and `style` props on the node definition: + +```typescript +const nodes: Node[] = [ + { + id: '1', + type: 'custom', + data: { label: 'Styled Node' }, + position: { x: 250, y: 5 }, + style: { border: '1px solid #777', padding: 10 }, + className: 'custom-node', + }, +]; +``` + +### Inline Styles in Component + +```typescript +import { CSSProperties } from 'react'; + +const nodeStyles: CSSProperties = { padding: 10, border: '1px solid #ddd' }; + +function StyledNode({ data }: NodeProps) { + return ( +
+ {data.label} +
+ ); +} +``` + +### Tailwind CSS + +React Flow works seamlessly with Tailwind: + +```typescript +function TailwindNode({ data }: NodeProps) { + return ( +
+
+
+
{data.name}
+
{data.job}
+
+
+ + +
+ ); +} +``` + +## Aviation Map Pin Node Example + +Custom node with status-based styling using data-driven approach: + +```typescript +import { NodeProps, Handle, Position } from '@xyflow/react'; + +type MapPinData = { + label: string; + status: 'active' | 'warning' | 'inactive'; + coordinate: { lat: number; lon: number }; +}; + +export type MapPinNode = Node; + +function MapPinNode({ data, selected }: NodeProps) { + const statusColors = { + active: 'bg-green-500', + warning: 'bg-yellow-500', + inactive: 'bg-gray-400', + }; + + return ( +
+ {/* Beacon glow for active status */} + {data.status === 'active' && ( +
+ )} + + {/* Pin icon */} +
+
+ {data.label} +
+
+ + {/* Connection handle at bottom */} + +
+ ); +} +``` + +## Preventing Drag and Pan + +Use `nodrag` and `nopan` classes to prevent interactions on specific elements: + +```typescript +function InteractiveNode({ data }: NodeProps) { + return ( +
+ + +
+ ); +} +``` + +## Node Registration + +Register custom nodes in the `nodeTypes` prop: + +```typescript +import { ReactFlow, NodeTypes } from '@xyflow/react'; +import CustomNode from './CustomNode'; +import MapPinNode from './MapPinNode'; + +const nodeTypes: NodeTypes = { + custom: CustomNode, + mapPin: MapPinNode, +}; + +function Flow() { + return ( + + ); +} +``` diff --git a/.agents/skills/react-flow/references/events.md b/.agents/skills/react-flow/references/events.md new file mode 100644 index 0000000..7056e6a --- /dev/null +++ b/.agents/skills/react-flow/references/events.md @@ -0,0 +1,629 @@ +# Events + +React Flow provides comprehensive event handling for nodes, edges, connections, selections, and viewport changes. + +## Table of Contents + +- [Node Events](#node-events) + - [Click Events](#click-events) + - [Drag Events](#drag-events) + - [Hover Events](#hover-events) +- [Edge Events](#edge-events) + - [Click Events](#click-events-1) + - [Hover Events](#hover-events-1) + - [Edge Update and Reconnect](#edge-update-and-reconnect) +- [Connection Events](#connection-events) + - [Basic Connection](#basic-connection) + - [Connection Start and End](#connection-start-and-end) + - [Validate Connections](#validate-connections) +- [Selection Events](#selection-events) + - [useOnSelectionChange Hook](#useonselectionchange-hook) + - [Selection Drag](#selection-drag) + - [Selection Context Menu](#selection-context-menu) +- [Viewport Events](#viewport-events) + - [useOnViewportChange Hook](#useonviewportchange-hook) + - [Move Events](#move-events) +- [Pane Events](#pane-events) + - [Click Events](#click-events-2) + - [Mouse Events](#mouse-events) +- [Init and Delete Events](#init-and-delete-events) + - [Initialization](#initialization) + - [Delete Events](#delete-events) +- [Error Handling](#error-handling) + +## Node Events + +### Click Events + +```typescript +import { ReactFlow, NodeMouseHandler, Node } from '@xyflow/react'; + +function NodeClickExample() { + const onNodeClick: NodeMouseHandler = (event, node) => { + console.log('Node clicked:', node.id, node.data); + }; + + const onNodeDoubleClick: NodeMouseHandler = (event, node) => { + console.log('Node double-clicked:', node.id); + }; + + const onNodeContextMenu: NodeMouseHandler = (event, node) => { + event.preventDefault(); + console.log('Node right-clicked:', node.id); + }; + + return ( + + ); +} +``` + +### Drag Events + +```typescript +import { ReactFlow, OnNodeDrag, NodeMouseHandler } from '@xyflow/react'; + +function NodeDragExample() { + const onNodeDragStart: NodeMouseHandler = (event, node) => { + console.log('Drag started:', node.id); + }; + + const onNodeDrag: OnNodeDrag = (event, node, nodes) => { + console.log('Dragging:', node.id, 'at', node.position); + console.log('All dragged nodes:', nodes.map(n => n.id)); + }; + + const onNodeDragStop: NodeMouseHandler = (event, node) => { + console.log('Drag stopped:', node.id, 'at', node.position); + }; + + return ( + + ); +} +``` + +### Hover Events + +```typescript +import { ReactFlow, NodeMouseHandler } from '@xyflow/react'; + +function NodeHoverExample() { + const onNodeMouseEnter: NodeMouseHandler = (event, node) => { + console.log('Mouse entered:', node.id); + }; + + const onNodeMouseMove: NodeMouseHandler = (event, node) => { + console.log('Mouse moving over:', node.id); + }; + + const onNodeMouseLeave: NodeMouseHandler = (event, node) => { + console.log('Mouse left:', node.id); + }; + + return ( + + ); +} +``` + +## Edge Events + +### Click Events + +```typescript +import { ReactFlow, EdgeMouseHandler } from '@xyflow/react'; + +function EdgeClickExample() { + const onEdgeClick: EdgeMouseHandler = (event, edge) => { + console.log('Edge clicked:', edge.id); + console.log('From:', edge.source, 'To:', edge.target); + }; + + const onEdgeDoubleClick: EdgeMouseHandler = (event, edge) => { + console.log('Edge double-clicked:', edge.id); + }; + + const onEdgeContextMenu: EdgeMouseHandler = (event, edge) => { + event.preventDefault(); + console.log('Edge right-clicked:', edge.id); + }; + + return ( + + ); +} +``` + +### Hover Events + +```typescript +import { ReactFlow, EdgeMouseHandler } from '@xyflow/react'; + +function EdgeHoverExample() { + const onEdgeMouseEnter: EdgeMouseHandler = (event, edge) => { + console.log('Mouse entered edge:', edge.id); + }; + + const onEdgeMouseMove: EdgeMouseHandler = (event, edge) => { + console.log('Mouse moving over edge:', edge.id); + }; + + const onEdgeMouseLeave: EdgeMouseHandler = (event, edge) => { + console.log('Mouse left edge:', edge.id); + }; + + return ( + + ); +} +``` + +### Edge Update and Reconnect + +```typescript +import { ReactFlow, OnReconnect, OnReconnectStart, OnReconnectEnd } from '@xyflow/react'; + +function EdgeReconnectExample() { + const onReconnect: OnReconnect = (oldEdge, newConnection) => { + console.log('Edge reconnected:', oldEdge.id); + console.log('New connection:', newConnection); + }; + + const onReconnectStart: OnReconnectStart = (event, edge, handleType) => { + console.log('Reconnect started:', edge.id, 'handle:', handleType); + }; + + const onReconnectEnd: OnReconnectEnd = (event, edge, handleType, connectionState) => { + console.log('Reconnect ended:', edge.id); + console.log('Connection state:', connectionState); + }; + + return ( + + ); +} +``` + +## Connection Events + +### Basic Connection + +```typescript +import { ReactFlow, OnConnect, addEdge } from '@xyflow/react'; +import { useCallback } from 'react'; + +function ConnectionExample() { + const [edges, setEdges] = useState([]); + + const onConnect: OnConnect = useCallback( + (connection) => { + console.log('Connection made:', connection); + console.log('Source:', connection.source); + console.log('Target:', connection.target); + console.log('Source Handle:', connection.sourceHandle); + console.log('Target Handle:', connection.targetHandle); + + setEdges((eds) => addEdge(connection, eds)); + }, + [setEdges] + ); + + return ( + + ); +} +``` + +### Connection Start and End + +```typescript +import { ReactFlow, OnConnectStart, OnConnectEnd } from '@xyflow/react'; + +function ConnectionLifecycleExample() { + const onConnectStart: OnConnectStart = (event, { nodeId, handleId, handleType }) => { + console.log('Connection started from:', nodeId); + console.log('Handle:', handleId, 'Type:', handleType); + }; + + const onConnectEnd: OnConnectEnd = (event, connectionState) => { + console.log('Connection ended'); + console.log('Was valid:', connectionState.isValid); + console.log('From node:', connectionState.fromNode?.id); + console.log('To node:', connectionState.toNode?.id); + console.log('From handle:', connectionState.fromHandle); + console.log('To handle:', connectionState.toHandle); + }; + + return ( + + ); +} +``` + +### Validate Connections + +```typescript +import { ReactFlow, Connection, Edge, Node } from '@xyflow/react'; + +function ValidatedConnectionExample() { + const isValidConnection = (connection: Connection | Edge) => { + // Prevent self-connections + if (connection.source === connection.target) { + return false; + } + + // Custom validation logic + const sourceNode = nodes.find(n => n.id === connection.source); + const targetNode = nodes.find(n => n.id === connection.target); + + // Prevent connections from output nodes + if (sourceNode?.type === 'output') { + return false; + } + + // Prevent connections to input nodes + if (targetNode?.type === 'input') { + return false; + } + + return true; + }; + + return ( + + ); +} +``` + +## Selection Events + +### useOnSelectionChange Hook + +```typescript +import { useOnSelectionChange, OnSelectionChangeParams } from '@xyflow/react'; +import { useCallback } from 'react'; + +function SelectionLogger() { + const onChange = useCallback(({ nodes, edges }: OnSelectionChangeParams) => { + console.log('Selected nodes:', nodes.map(n => n.id)); + console.log('Selected edges:', edges.map(e => e.id)); + }, []); + + useOnSelectionChange({ + onChange, + }); + + return null; +} + +function SelectionExample() { + return ( + + + + ); +} +``` + +### Selection Drag + +```typescript +import { ReactFlow, SelectionDragHandler } from '@xyflow/react'; + +function SelectionDragExample() { + const onSelectionDragStart: SelectionDragHandler = (event, nodes) => { + console.log('Selection drag started:', nodes.length, 'nodes'); + }; + + const onSelectionDrag: SelectionDragHandler = (event, nodes) => { + console.log('Dragging selection:', nodes.map(n => n.id)); + }; + + const onSelectionDragStop: SelectionDragHandler = (event, nodes) => { + console.log('Selection drag stopped'); + }; + + return ( + + ); +} +``` + +### Selection Context Menu + +```typescript +import { ReactFlow, Node, Edge } from '@xyflow/react'; + +function SelectionContextMenuExample() { + const onSelectionContextMenu = (event: React.MouseEvent, nodes: Node[]) => { + event.preventDefault(); + console.log('Context menu on selection:', nodes.map(n => n.id)); + + // Show custom context menu + // ... context menu logic + }; + + return ( + + ); +} +``` + +## Viewport Events + +### useOnViewportChange Hook + +```typescript +import { useOnViewportChange, Viewport } from '@xyflow/react'; +import { useCallback } from 'react'; + +function ViewportLogger() { + const onStart = useCallback((viewport: Viewport) => { + console.log('Viewport change started:', viewport); + }, []); + + const onChange = useCallback((viewport: Viewport) => { + console.log('Viewport:', { + x: viewport.x, + y: viewport.y, + zoom: viewport.zoom, + }); + }, []); + + const onEnd = useCallback((viewport: Viewport) => { + console.log('Viewport change ended:', viewport); + }, []); + + useOnViewportChange({ + onStart, + onChange, + onEnd, + }); + + return null; +} +``` + +### Move Events + +```typescript +import { ReactFlow, OnMove } from '@xyflow/react'; + +function MoveExample() { + const onMove: OnMove = (event, viewport) => { + console.log('Viewport moved to:', viewport); + }; + + const onMoveStart: OnMove = (event, viewport) => { + console.log('Move started from:', viewport); + }; + + const onMoveEnd: OnMove = (event, viewport) => { + console.log('Move ended at:', viewport); + }; + + return ( + + ); +} +``` + +## Pane Events + +### Click Events + +```typescript +import { ReactFlow } from '@xyflow/react'; +import { MouseEvent } from 'react'; + +function PaneClickExample() { + const onPaneClick = (event: MouseEvent) => { + console.log('Pane clicked at:', event.clientX, event.clientY); + }; + + const onPaneContextMenu = (event: MouseEvent) => { + event.preventDefault(); + console.log('Pane right-clicked'); + }; + + const onPaneScroll = (event?: MouseEvent | WheelEvent) => { + console.log('Pane scrolled'); + }; + + return ( + + ); +} +``` + +### Mouse Events + +```typescript +import { ReactFlow } from '@xyflow/react'; +import { MouseEvent } from 'react'; + +function PaneMouseExample() { + const onPaneMouseEnter = (event: MouseEvent) => { + console.log('Mouse entered pane'); + }; + + const onPaneMouseMove = (event: MouseEvent) => { + console.log('Mouse moving over pane'); + }; + + const onPaneMouseLeave = (event: MouseEvent) => { + console.log('Mouse left pane'); + }; + + return ( + + ); +} +``` + +## Init and Delete Events + +### Initialization + +```typescript +import { ReactFlow, OnInit, ReactFlowInstance } from '@xyflow/react'; + +function InitExample() { + const onInit: OnInit = (reactFlowInstance: ReactFlowInstance) => { + console.log('React Flow initialized'); + console.log('Viewport:', reactFlowInstance.getViewport()); + reactFlowInstance.fitView(); + }; + + return ( + + ); +} +``` + +### Delete Events + +```typescript +import { ReactFlow, OnNodesDelete, OnEdgesDelete, OnBeforeDelete } from '@xyflow/react'; + +function DeleteExample() { + const onNodesDelete: OnNodesDelete = (nodes) => { + console.log('Nodes deleted:', nodes.map(n => n.id)); + }; + + const onEdgesDelete: OnEdgesDelete = (edges) => { + console.log('Edges deleted:', edges.map(e => e.id)); + }; + + const onBeforeDelete: OnBeforeDelete = async ({ nodes, edges }) => { + console.log('About to delete:', nodes.length, 'nodes and', edges.length, 'edges'); + + // Return true to allow deletion, false to cancel + const confirmed = window.confirm('Delete selected elements?'); + return confirmed; + }; + + const onDelete = ({ nodes, edges }) => { + console.log('Deleted:', nodes.length, 'nodes and', edges.length, 'edges'); + }; + + return ( + + ); +} +``` + +## Error Handling + +```typescript +import { ReactFlow, OnError } from '@xyflow/react'; + +function ErrorHandlingExample() { + const onError: OnError = (code, message) => { + console.error(`React Flow Error [${code}]:`, message); + + // Handle specific error codes + if (code === '010') { + console.error('Handle must be rendered inside a custom node'); + } + }; + + return ( + + ); +} +``` diff --git a/.agents/skills/react-flow/references/viewport.md b/.agents/skills/react-flow/references/viewport.md new file mode 100644 index 0000000..d0dd1bc --- /dev/null +++ b/.agents/skills/react-flow/references/viewport.md @@ -0,0 +1,434 @@ +# Viewport Control + +React Flow provides viewport control through the `useReactFlow()` hook, which exposes methods for programmatic navigation, zoom, and coordinate transformations. + +## Table of Contents + +- [useReactFlow Hook](#usereactflow-hook) +- [fitView Method](#fitview-method) +- [Zoom Methods](#zoom-methods) +- [setViewport Method](#setviewport-method) +- [setCenter Method](#setcenter-method) +- [screenToFlowPosition Method](#screentoflowposition-method) +- [flowToScreenPosition Method](#flowtoscreenposition-method) +- [Save and Restore Viewport State](#save-and-restore-viewport-state) +- [Programmatic Pan to Node](#programmatic-pan-to-node) +- [Controlled Viewport](#controlled-viewport) +- [useOnViewportChange Hook](#useonviewportchange-hook) +- [getNodesBounds Method](#getnodesbounds-method) +- [viewportInitialized Flag](#viewportinitialized-flag) + +## useReactFlow Hook + +The main hook for accessing viewport and flow instance methods: + +```typescript +import { useReactFlow } from '@xyflow/react'; + +function ViewportControls() { + const reactFlow = useReactFlow(); + + // Access viewport methods + const handleZoomIn = () => reactFlow.zoomIn(); + const handleFitView = () => reactFlow.fitView(); + + return ( +
+ + +
+ ); +} +``` + +## fitView Method + +Adjusts the viewport to fit all nodes in view: + +```typescript +import { useReactFlow, FitViewOptions } from '@xyflow/react'; + +function FitViewExample() { + const { fitView } = useReactFlow(); + + const handleFitView = async () => { + // Basic usage + await fitView(); + + // With options + await fitView({ + padding: 0.2, // 20% padding around nodes + includeHiddenNodes: false, // Don't include hidden nodes + minZoom: 0.5, // Minimum zoom level + maxZoom: 2, // Maximum zoom level + duration: 200, // Animation duration in ms + }); + }; + + return ; +} +``` + +### fitView with Specific Nodes + +Fit viewport to a subset of nodes: + +```typescript +import { useReactFlow } from '@xyflow/react'; + +function FitSpecificNodes() { + const { fitView, getNodes } = useReactFlow(); + + const fitSelectedNodes = async () => { + const selectedNodes = getNodes().filter(node => node.selected); + + if (selectedNodes.length > 0) { + await fitView({ + nodes: selectedNodes, + padding: 0.3, + duration: 400, + }); + } + }; + + return ; +} +``` + +## Zoom Methods + +```typescript +import { useReactFlow } from '@xyflow/react'; + +function ZoomControls() { + const { zoomIn, zoomOut, zoomTo, getZoom } = useReactFlow(); + + const handleZoomIn = () => { + zoomIn({ duration: 300 }); // Animated zoom + }; + + const handleZoomOut = () => { + zoomOut({ duration: 300 }); + }; + + const handleZoomTo = () => { + zoomTo(1.5, { duration: 500 }); // Zoom to specific level + }; + + const handleGetZoom = () => { + const currentZoom = getZoom(); + console.log('Current zoom:', currentZoom); + }; + + return ( +
+ + + + +
+ ); +} +``` + +## setViewport Method + +Directly set the viewport position and zoom: + +```typescript +import { useReactFlow, Viewport } from '@xyflow/react'; + +function ViewportSetter() { + const { setViewport, getViewport } = useReactFlow(); + + const handleSetViewport = () => { + const newViewport: Viewport = { + x: 100, + y: 100, + zoom: 1.2, + }; + + setViewport(newViewport, { duration: 400 }); + }; + + const handleGetViewport = () => { + const viewport = getViewport(); + console.log('Current viewport:', viewport); + // { x: 0, y: 0, zoom: 1 } + }; + + return ( +
+ + +
+ ); +} +``` + +## setCenter Method + +Center the viewport on specific coordinates: + +```typescript +import { useReactFlow } from '@xyflow/react'; + +function CenterControls() { + const { setCenter } = useReactFlow(); + + const centerOnPosition = () => { + setCenter( + 250, // x coordinate + 250, // y coordinate + { + zoom: 1.5, + duration: 500, + } + ); + }; + + return ; +} +``` + +## screenToFlowPosition Method + +Convert screen coordinates to flow coordinates: + +```typescript +import { useReactFlow } from '@xyflow/react'; +import { MouseEvent } from 'react'; + +function ClickToAddNode() { + const { screenToFlowPosition, setNodes } = useReactFlow(); + + const handlePaneClick = (event: MouseEvent) => { + // Convert click position to flow coordinates + const position = screenToFlowPosition({ + x: event.clientX, + y: event.clientY, + }); + + // Add node at click position + setNodes((nodes) => [ + ...nodes, + { + id: `node-${Date.now()}`, + position, + data: { label: 'New Node' }, + }, + ]); + }; + + return ; +} +``` + +## flowToScreenPosition Method + +Convert flow coordinates to screen coordinates: + +```typescript +import { useReactFlow } from '@xyflow/react'; + +function PositionConverter() { + const { flowToScreenPosition } = useReactFlow(); + + const getScreenPosition = () => { + const screenPos = flowToScreenPosition({ + x: 100, + y: 100, + }); + console.log('Screen position:', screenPos); + }; + + return ; +} +``` + +## Save and Restore Viewport State + +```typescript +import { useState } from 'react'; +import { useReactFlow, Viewport } from '@xyflow/react'; + +function ViewportPersistence() { + const { setViewport, getViewport } = useReactFlow(); + const [savedViewport, setSavedViewport] = useState(null); + + const saveViewport = () => { + const viewport = getViewport(); + setSavedViewport(viewport); + // Optionally save to localStorage + localStorage.setItem('flowViewport', JSON.stringify(viewport)); + }; + + const restoreViewport = () => { + if (savedViewport) { + setViewport(savedViewport, { duration: 300 }); + } else { + // Load from localStorage + const stored = localStorage.getItem('flowViewport'); + if (stored) { + const viewport = JSON.parse(stored) as Viewport; + setViewport(viewport, { duration: 300 }); + } + } + }; + + return ( +
+ + +
+ ); +} +``` + +## Programmatic Pan to Node + +Pan the viewport to focus on a specific node: + +```typescript +import { useReactFlow } from '@xyflow/react'; + +function PanToNode() { + const { getNode, setCenter } = useReactFlow(); + + const panToNodeById = (nodeId: string) => { + const node = getNode(nodeId); + + if (node) { + const x = node.position.x + (node.width ?? 0) / 2; + const y = node.position.y + (node.height ?? 0) / 2; + + setCenter(x, y, { zoom: 1.5, duration: 500 }); + } + }; + + return ( + + ); +} +``` + +## Controlled Viewport + +Control viewport directly through state: + +```typescript +import { useState, useCallback } from 'react'; +import { ReactFlow, Viewport, useReactFlow } from '@xyflow/react'; + +function ControlledViewportFlow() { + const [viewport, setViewport] = useState({ x: 0, y: 0, zoom: 1 }); + const { fitView } = useReactFlow(); + + const handleViewportChange = useCallback((newViewport: Viewport) => { + setViewport(newViewport); + }, []); + + const updateViewport = () => { + setViewport((vp) => ({ ...vp, y: vp.y + 10 })); + }; + + return ( + <> + + + + + + ); +} +``` + +## useOnViewportChange Hook + +Listen to viewport changes: + +```typescript +import { useOnViewportChange, Viewport } from '@xyflow/react'; +import { useCallback } from 'react'; + +function ViewportLogger() { + const onStart = useCallback((viewport: Viewport) => { + console.log('Viewport change started:', viewport); + }, []); + + const onChange = useCallback((viewport: Viewport) => { + console.log('Viewport changing:', viewport); + }, []); + + const onEnd = useCallback((viewport: Viewport) => { + console.log('Viewport change ended:', viewport); + }, []); + + useOnViewportChange({ + onStart, + onChange, + onEnd, + }); + + return null; +} +``` + +## getNodesBounds Method + +Get bounding box of specific nodes: + +```typescript +import { useReactFlow } from '@xyflow/react'; + +function NodeBounds() { + const { getNodesBounds, getNodes } = useReactFlow(); + + const logSelectedBounds = () => { + const selectedNodes = getNodes().filter(n => n.selected); + const bounds = getNodesBounds(selectedNodes); + + console.log('Bounds:', { + x: bounds.x, + y: bounds.y, + width: bounds.width, + height: bounds.height, + }); + }; + + return ; +} +``` + +## viewportInitialized Flag + +Check if viewport is initialized before using methods: + +```typescript +import { useReactFlow } from '@xyflow/react'; + +function SafeViewportControls() { + const { viewportInitialized, fitView } = useReactFlow(); + + const handleFitView = () => { + if (viewportInitialized) { + fitView(); + } else { + console.warn('Viewport not yet initialized'); + } + }; + + return ( + + ); +} +``` diff --git a/.agents/skills/tanstack-start-best-practices/SKILL.md b/.agents/skills/tanstack-start-best-practices/SKILL.md new file mode 100644 index 0000000..3bf3835 --- /dev/null +++ b/.agents/skills/tanstack-start-best-practices/SKILL.md @@ -0,0 +1,109 @@ +--- +name: tanstack-start-best-practices +description: TanStack Start best practices for full-stack React applications. Server functions, middleware, SSR, authentication, and deployment patterns. Activate when building full-stack apps with TanStack Start. +--- + +# TanStack Start Best Practices + +Comprehensive guidelines for implementing TanStack Start patterns in full-stack React applications. These rules cover server functions, middleware, SSR, authentication, and deployment. + +## When to Apply + +- Creating server functions for data mutations +- Setting up middleware for auth/logging +- Configuring SSR and hydration +- Implementing authentication flows +- Handling errors across client/server boundary +- Organizing full-stack code +- Deploying to various platforms + +## Rule Categories by Priority + +| Priority | Category | Rules | Impact | +|----------|----------|-------|--------| +| CRITICAL | Server Functions | 5 rules | Core data mutation patterns | +| CRITICAL | Security | 4 rules | Prevents vulnerabilities | +| HIGH | Middleware | 4 rules | Request/response handling | +| HIGH | Authentication | 4 rules | Secure user sessions | +| MEDIUM | API Routes | 1 rule | External endpoint patterns | +| MEDIUM | SSR | 6 rules | Server rendering patterns | +| MEDIUM | Error Handling | 3 rules | Graceful failure handling | +| MEDIUM | Environment | 1 rule | Configuration management | +| LOW | File Organization | 3 rules | Maintainable code structure | +| LOW | Deployment | 2 rules | Production readiness | + +## Quick Reference + +### Server Functions (Prefix: `sf-`) + +- `sf-create-server-fn` — Use createServerFn for server-side logic +- `sf-input-validation` — Always validate server function inputs +- `sf-method-selection` — Choose appropriate HTTP method +- `sf-error-handling` — Handle errors in server functions +- `sf-response-headers` — Customize response headers when needed + +### Security (Prefix: `sec-`) + +- `sec-validate-inputs` — Validate all user inputs with schemas +- `sec-auth-middleware` — Protect routes with auth middleware +- `sec-sensitive-data` — Keep secrets server-side only +- `sec-csrf-protection` — Implement CSRF protection for mutations + +### Middleware (Prefix: `mw-`) + +- `mw-request-middleware` — Use request middleware for cross-cutting concerns +- `mw-function-middleware` — Use function middleware for server functions +- `mw-context-flow` — Properly pass context through middleware +- `mw-composability` — Compose middleware effectively + +### Authentication (Prefix: `auth-`) + +- `auth-session-management` — Implement secure session handling +- `auth-route-protection` — Protect routes with beforeLoad +- `auth-server-functions` — Verify auth in server functions +- `auth-cookie-security` — Configure secure cookie settings + +### API Routes (Prefix: `api-`) + +- `api-routes` — Create API routes for external consumers + +### SSR (Prefix: `ssr-`) + +- `ssr-data-loading` — Load data appropriately for SSR +- `ssr-hydration-safety` — Prevent hydration mismatches +- `ssr-streaming` — Implement streaming SSR for faster TTFB +- `ssr-selective` — Apply selective SSR when beneficial +- `ssr-prerender` — Configure static prerendering and ISR + +### Environment (Prefix: `env-`) + +- `env-functions` — Use environment functions for configuration + +### Error Handling (Prefix: `err-`) + +- `err-server-errors` — Handle server function errors +- `err-redirects` — Use redirects appropriately +- `err-not-found` — Handle not-found scenarios + +### File Organization (Prefix: `file-`) + +- `file-separation` — Separate server and client code +- `file-functions-file` — Use .functions.ts pattern +- `file-shared-validation` — Share validation schemas + +### Deployment (Prefix: `deploy-`) + +- `deploy-env-config` — Configure environment variables +- `deploy-adapters` — Choose appropriate deployment adapter + +## How to Use + +Each rule file in the `rules/` directory contains: +1. **Explanation** — Why this pattern matters +2. **Bad Example** — Anti-pattern to avoid +3. **Good Example** — Recommended implementation +4. **Context** — When to apply or skip this rule + +## Full Reference + +See individual rule files in `rules/` directory for detailed guidance and code examples. diff --git a/.agents/skills/tanstack-start-best-practices/rules/api-routes.md b/.agents/skills/tanstack-start-best-practices/rules/api-routes.md new file mode 100644 index 0000000..1acdc7f --- /dev/null +++ b/.agents/skills/tanstack-start-best-practices/rules/api-routes.md @@ -0,0 +1,238 @@ +# api-routes: Create Server Routes for External Consumers + +## Priority: MEDIUM + +## Explanation + +While server functions are ideal for internal RPC, server routes provide traditional REST endpoints for external consumers, webhooks, and integrations. Use server routes when you need standard HTTP semantics, custom response formats, or third-party compatibility. + +## Bad Example + +```tsx +// Using server functions for webhook endpoints +export const stripeWebhook = createServerFn({ method: 'POST' }) + .handler(async ({ request }) => { + // Server functions aren't designed for raw request handling + // No easy access to raw body for signature verification + // Response format is JSON by default + }) + +// Or exposing internal functions to external consumers +export const getUsers = createServerFn() + .handler(async () => { + return db.users.findMany() + }) +// No versioning, no standard REST semantics +``` + +## Good Example: Basic Server Route + +```tsx +// routes/api/users.ts +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' + +export const Route = createFileRoute('/api/users')({ + server: { + handlers: { + GET: async ({ request }) => { + const users = await db.users.findMany({ + select: { id: true, name: true, email: true }, + }) + + return json(users, { + headers: { + 'Cache-Control': 'public, max-age=60', + }, + }) + }, + + POST: async ({ request }) => { + const body = await request.json() + + // Validate input + const parsed = createUserSchema.safeParse(body) + if (!parsed.success) { + return json({ error: parsed.error.flatten() }, { status: 400 }) + } + + const user = await db.users.create({ data: parsed.data }) + return json(user, { status: 201 }) + }, + }, + }, +}) +``` + +## Good Example: Webhook Handler + +```tsx +// routes/api/webhooks/stripe.ts +import { createFileRoute } from '@tanstack/react-router' +import Stripe from 'stripe' + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!) + +export const Route = createFileRoute('/api/webhooks/stripe')({ + server: { + handlers: { + POST: async ({ request }) => { + const signature = request.headers.get('stripe-signature') + if (!signature) { + return new Response('Missing signature', { status: 400 }) + } + + // Get raw body for signature verification + const rawBody = await request.text() + + let event: Stripe.Event + try { + event = stripe.webhooks.constructEvent( + rawBody, + signature, + process.env.STRIPE_WEBHOOK_SECRET! + ) + } catch (err) { + console.error('Webhook signature verification failed:', err) + return new Response('Invalid signature', { status: 400 }) + } + + // Handle the event + switch (event.type) { + case 'checkout.session.completed': + await handleCheckoutComplete(event.data.object) + break + case 'customer.subscription.updated': + await handleSubscriptionUpdate(event.data.object) + break + default: + console.log(`Unhandled event type: ${event.type}`) + } + + return new Response('OK', { status: 200 }) + }, + }, + }, +}) +``` + +## Good Example: RESTful Resource with Dynamic Params + +```tsx +// routes/api/posts/$postId.ts +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' + +export const Route = createFileRoute('/api/posts/$postId')({ + server: { + handlers: { + GET: async ({ params }) => { + const post = await db.posts.findUnique({ + where: { id: params.postId }, + }) + + if (!post) { + return json({ error: 'Post not found' }, { status: 404 }) + } + + return json(post) + }, + + PUT: async ({ request, params }) => { + const body = await request.json() + const parsed = updatePostSchema.safeParse(body) + + if (!parsed.success) { + return json({ error: parsed.error.flatten() }, { status: 400 }) + } + + const post = await db.posts.update({ + where: { id: params.postId }, + data: parsed.data, + }) + + return json(post) + }, + + DELETE: async ({ params }) => { + await db.posts.delete({ where: { id: params.postId } }) + return new Response(null, { status: 204 }) + }, + }, + }, +}) +``` + +## Good Example: With Route-Level Middleware + +```tsx +// routes/api/protected/data.ts +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import { apiKeyMiddleware } from '@/lib/middleware' + +export const Route = createFileRoute('/api/protected/data')({ + server: { + // Middleware applies to all handlers in this route + middleware: [apiKeyMiddleware], + handlers: { + GET: async ({ request, context }) => { + // context.client available from middleware + const data = await fetchDataForClient(context.client.id) + return json(data) + }, + }, + }, +}) +``` + +## Good Example: Using createHandlers for Handler-Specific Middleware + +```tsx +// routes/api/admin/users.ts +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' + +export const Route = createFileRoute('/api/admin/users')({ + server: { + middleware: [authMiddleware], // All handlers require auth + handlers: (createHandlers) => ({ + GET: createHandlers.GET(async ({ context }) => { + const users = await db.users.findMany() + return json(users) + }), + + // DELETE requires additional admin middleware + DELETE: createHandlers.DELETE({ + middleware: [adminOnlyMiddleware], + handler: async ({ request, context }) => { + const { userId } = await request.json() + await db.users.delete({ where: { id: userId } }) + return json({ deleted: true }) + }, + }), + }), + }, +}) +``` + +## Server Functions vs Server Routes + +| Feature | Server Functions | Server Routes | +|---------|-----------------|--------------| +| Primary use | Internal RPC | External consumers | +| Type safety | Full end-to-end | Manual | +| Response format | JSON (automatic) | Any (manual) | +| Raw request access | Limited | Full | +| URL structure | Auto-generated | Explicit paths | +| Webhooks | Not ideal | Designed for | + +## Context + +- Server routes use `createFileRoute` with a `server.handlers` property +- Support all HTTP methods: GET, POST, PUT, PATCH, DELETE, etc. +- Use `json()` helper for JSON responses +- Return `Response` objects for custom formats +- Handler receives `{ request, params }` object +- Ideal for: webhooks, public APIs, file downloads, third-party integrations +- Consider versioning: `/api/v1/users` for public APIs diff --git a/.agents/skills/tanstack-start-best-practices/rules/auth-route-protection.md b/.agents/skills/tanstack-start-best-practices/rules/auth-route-protection.md new file mode 100644 index 0000000..4756c33 --- /dev/null +++ b/.agents/skills/tanstack-start-best-practices/rules/auth-route-protection.md @@ -0,0 +1,192 @@ +# auth-route-protection: Protect Routes with beforeLoad + +## Priority: HIGH + +## Explanation + +Use `beforeLoad` in route definitions to check authentication before the route loads. This prevents unauthorized access, redirects to login, and can extend context with user data for child routes. + +## Bad Example + +```tsx +// Checking auth in component - too late, data may have loaded +function DashboardPage() { + const user = useAuth() + + useEffect(() => { + if (!user) { + navigate({ to: '/login' }) // Redirect after render + } + }, [user]) + + if (!user) return null // Flash of content possible + + return +} + +// No protection on route +export const Route = createFileRoute('/dashboard')({ + loader: async () => { + // Fetches sensitive data even for unauthenticated users + return await fetchDashboardData() + }, + component: DashboardPage, +}) +``` + +## Good Example: Route-Level Protection + +```tsx +// routes/_authenticated.tsx - Layout route for protected area +import { createFileRoute, redirect, Outlet } from '@tanstack/react-router' +import { getSessionData } from '@/lib/session.server' + +export const Route = createFileRoute('/_authenticated')({ + beforeLoad: async ({ location }) => { + const session = await getSessionData() + + if (!session) { + throw redirect({ + to: '/login', + search: { + redirect: location.href, + }, + }) + } + + // Extend context with user for all child routes + return { + user: session, + } + }, + component: AuthenticatedLayout, +}) + +function AuthenticatedLayout() { + return ( +
+ +
+ {/* Child routes render here */} +
+
+ ) +} + +// routes/_authenticated/dashboard.tsx +// This route is automatically protected by parent +export const Route = createFileRoute('/_authenticated/dashboard')({ + loader: async ({ context }) => { + // context.user is guaranteed to exist + return await fetchDashboardData(context.user.id) + }, + component: DashboardPage, +}) + +function DashboardPage() { + const data = Route.useLoaderData() + const { user } = Route.useRouteContext() + + return +} +``` + +## Good Example: Role-Based Access + +```tsx +// routes/_admin.tsx +export const Route = createFileRoute('/_admin')({ + beforeLoad: async ({ context }) => { + // context.user comes from parent _authenticated route + if (context.user.role !== 'admin') { + throw redirect({ to: '/unauthorized' }) + } + }, + component: AdminLayout, +}) + +// File structure: +// routes/ +// _authenticated.tsx # Requires login +// _authenticated/ +// dashboard.tsx # /dashboard - any authenticated user +// settings.tsx # /settings - any authenticated user +// _admin.tsx # Admin layout +// _admin/ +// users.tsx # /users - admin only +// analytics.tsx # /analytics - admin only +``` + +## Good Example: Preserving Redirect URL + +```tsx +// routes/login.tsx +import { z } from 'zod' + +export const Route = createFileRoute('/login')({ + validateSearch: z.object({ + redirect: z.string().optional(), + }), + component: LoginPage, +}) + +function LoginPage() { + const { redirect } = Route.useSearch() + const loginMutation = useMutation({ + mutationFn: login, + onSuccess: () => { + // Redirect to original destination or default + navigate({ to: redirect ?? '/dashboard' }) + }, + }) + + return +} + +// In protected routes +beforeLoad: async ({ location }) => { + if (!session) { + throw redirect({ + to: '/login', + search: { redirect: location.href }, + }) + } +} +``` + +## Good Example: Conditional Content Based on Auth + +```tsx +// Public route with different content for logged-in users +export const Route = createFileRoute('/')({ + beforeLoad: async () => { + const session = await getSessionData() + return { user: session?.user ?? null } + }, + component: HomePage, +}) + +function HomePage() { + const { user } = Route.useRouteContext() + + return ( +
+ + {user ? ( + + ) : ( + + )} +
+ ) +} +``` + +## Context + +- `beforeLoad` runs before route loading begins +- Throwing `redirect()` prevents route from loading +- Context from `beforeLoad` flows to loader and component +- Child routes inherit parent's `beforeLoad` protection +- Use pathless layout routes (`_authenticated.tsx`) for grouped protection +- Store redirect URL in search params for post-login navigation diff --git a/.agents/skills/tanstack-start-best-practices/rules/auth-session-management.md b/.agents/skills/tanstack-start-best-practices/rules/auth-session-management.md new file mode 100644 index 0000000..70c9b8b --- /dev/null +++ b/.agents/skills/tanstack-start-best-practices/rules/auth-session-management.md @@ -0,0 +1,191 @@ +# auth-session-management: Implement Secure Session Handling + +## Priority: HIGH + +## Explanation + +Sessions maintain user authentication state across requests. Use HTTP-only cookies with secure settings to prevent XSS and CSRF attacks. Never store sensitive data in client-accessible storage. + +## Bad Example + +```tsx +// Storing auth in localStorage - vulnerable to XSS +function login(credentials: Credentials) { + const token = await authenticate(credentials) + localStorage.setItem('authToken', token) // XSS can steal this +} + +// Non-HTTP-only cookie - JavaScript accessible +export const setSession = createServerFn({ method: 'POST' }) + .handler(async ({ data }) => { + setResponseHeader('Set-Cookie', `session=${data.token}`) // Not secure + }) +``` + +## Good Example: Secure Session Cookie + +```tsx +// lib/session.server.ts +import { useSession } from '@tanstack/react-start/server' + +// Configure session with secure defaults +export function getSession() { + return useSession({ + password: process.env.SESSION_SECRET!, // At least 32 characters + cookie: { + name: '__session', + httpOnly: true, // Not accessible via JavaScript + secure: process.env.NODE_ENV === 'production', // HTTPS only in prod + sameSite: 'lax', // CSRF protection + maxAge: 60 * 60 * 24 * 7, // 7 days + }, + }) +} + +// Usage in server function +export const login = createServerFn({ method: 'POST' }) + .validator(loginSchema) + .handler(async ({ data }) => { + const session = await getSession() + + // Verify credentials + const user = await verifyCredentials(data.email, data.password) + if (!user) { + throw new Error('Invalid credentials') + } + + // Store only essential data in session + await session.update({ + userId: user.id, + email: user.email, + createdAt: Date.now(), + }) + + return { success: true } + }) +``` + +## Good Example: Full Authentication Flow + +```tsx +// lib/auth.functions.ts +import { createServerFn } from '@tanstack/react-start' +import { redirect } from '@tanstack/react-router' +import { getSession } from './session.server' +import { hashPassword, verifyPassword } from './password.server' + +// Login +export const login = createServerFn({ method: 'POST' }) + .validator(z.object({ + email: z.string().email(), + password: z.string().min(1), + })) + .handler(async ({ data }) => { + const user = await db.users.findUnique({ + where: { email: data.email }, + }) + + if (!user || !await verifyPassword(data.password, user.passwordHash)) { + throw new Error('Invalid email or password') + } + + const session = await getSession() + await session.update({ + userId: user.id, + email: user.email, + }) + + throw redirect({ to: '/dashboard' }) + }) + +// Logout +export const logout = createServerFn({ method: 'POST' }) + .handler(async () => { + const session = await getSession() + await session.clear() + throw redirect({ to: '/' }) + }) + +// Get current user +export const getCurrentUser = createServerFn() + .handler(async () => { + const session = await getSession() + const data = await session.data + + if (!data?.userId) { + return null + } + + const user = await db.users.findUnique({ + where: { id: data.userId }, + select: { + id: true, + email: true, + name: true, + avatar: true, + // Don't include passwordHash! + }, + }) + + return user + }) +``` + +## Good Example: Session with Role-Based Access + +```tsx +// lib/session.server.ts +interface SessionData { + userId: string + email: string + role: 'user' | 'admin' + createdAt: number +} + +export async function getSessionData(): Promise { + const session = await getSession() + const data = await session.data + + if (!data?.userId) return null + + // Validate session age + const maxAge = 7 * 24 * 60 * 60 * 1000 // 7 days + if (Date.now() - data.createdAt > maxAge) { + await session.clear() + return null + } + + return data as SessionData +} + +// Middleware for admin-only routes +export const requireAdmin = createMiddleware() + .server(async ({ next }) => { + const session = await getSessionData() + + if (!session || session.role !== 'admin') { + throw redirect({ to: '/unauthorized' }) + } + + return next({ context: { session } }) + }) +``` + +## Session Security Checklist + +| Setting | Value | Purpose | +|---------|-------|---------| +| `httpOnly` | `true` | Prevents XSS from accessing cookie | +| `secure` | `true` in prod | Requires HTTPS | +| `sameSite` | `'lax'` or `'strict'` | CSRF protection | +| `maxAge` | Application-specific | Session duration | +| `password` | 32+ random chars | Encryption key | + +## Context + +- Always use HTTP-only cookies for session tokens +- Generate `SESSION_SECRET` with `openssl rand -base64 32` +- Store minimal data in session - fetch user details on demand +- Implement session rotation on privilege changes +- Consider session invalidation on password change +- Use `sameSite: 'strict'` for highest CSRF protection diff --git a/.agents/skills/tanstack-start-best-practices/rules/deploy-adapters.md b/.agents/skills/tanstack-start-best-practices/rules/deploy-adapters.md new file mode 100644 index 0000000..26e496d --- /dev/null +++ b/.agents/skills/tanstack-start-best-practices/rules/deploy-adapters.md @@ -0,0 +1,201 @@ +# deploy-adapters: Choose Appropriate Deployment Adapter + +## Priority: LOW + +## Explanation + +TanStack Start uses deployment adapters to target different hosting platforms. Each adapter optimizes the build output for its platform's runtime, edge functions, and static hosting capabilities. + +## Bad Example + +```tsx +// Not configuring adapter - using defaults may not match your host +// app.config.ts +export default defineConfig({ + // No adapter specified + // May not work correctly on your deployment platform +}) + +// Or using wrong adapter for platform +export default defineConfig({ + server: { + preset: 'node-server', // But deploying to Vercel Edge + }, +}) +``` + +## Good Example: Vercel Deployment + +```tsx +// app.config.ts +import { defineConfig } from '@tanstack/react-start/config' + +export default defineConfig({ + server: { + preset: 'vercel', + // Vercel-specific options + }, +}) + +// vercel.json (optional, for customization) +{ + "framework": null, + "buildCommand": "npm run build", + "outputDirectory": ".output" +} +``` + +## Good Example: Cloudflare Pages + +```tsx +// app.config.ts +import { defineConfig } from '@tanstack/react-start/config' + +export default defineConfig({ + server: { + preset: 'cloudflare-pages', + }, +}) + +// wrangler.toml +name = "my-tanstack-app" +compatibility_date = "2024-01-01" +pages_build_output_dir = ".output/public" + +// For Cloudflare Workers (full control) +export default defineConfig({ + server: { + preset: 'cloudflare', + }, +}) +``` + +## Good Example: Netlify + +```tsx +// app.config.ts +import { defineConfig } from '@tanstack/react-start/config' + +export default defineConfig({ + server: { + preset: 'netlify', + }, +}) + +// netlify.toml +[build] + command = "npm run build" + publish = ".output/public" + +[functions] + directory = ".output/server" +``` + +## Good Example: Node.js Server + +```tsx +// app.config.ts +import { defineConfig } from '@tanstack/react-start/config' + +export default defineConfig({ + server: { + preset: 'node-server', + // Optional: customize port + }, +}) + +// Dockerfile +FROM node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm ci --only=production +COPY .output .output +EXPOSE 3000 +CMD ["node", ".output/server/index.mjs"] + +// Or run directly +// node .output/server/index.mjs +``` + +## Good Example: Static Export (SPA) + +```tsx +// app.config.ts +import { defineConfig } from '@tanstack/react-start/config' + +export default defineConfig({ + server: { + preset: 'static', + prerender: { + routes: ['/'], + crawlLinks: true, + }, + }, +}) + +// Output: .output/public (static files only) +// Host anywhere: GitHub Pages, S3, any static host +``` + +## Good Example: AWS Lambda + +```tsx +// app.config.ts +import { defineConfig } from '@tanstack/react-start/config' + +export default defineConfig({ + server: { + preset: 'aws-lambda', + }, +}) + +// Deploy with SST, Serverless Framework, or AWS CDK +// serverless.yml example: +service: my-tanstack-app +provider: + name: aws + runtime: nodejs20.x +functions: + app: + handler: .output/server/index.handler + events: + - http: ANY / + - http: ANY /{proxy+} +``` + +## Good Example: Bun Runtime + +```tsx +// app.config.ts +import { defineConfig } from '@tanstack/react-start/config' + +export default defineConfig({ + server: { + preset: 'bun', + }, +}) + +// Run with: bun .output/server/index.mjs +``` + +## Adapter Comparison + +| Adapter | Runtime | Edge | Static | Best For | +|---------|---------|------|--------|----------| +| `vercel` | Node/Edge | Yes | Yes | Vercel hosting | +| `cloudflare-pages` | Workers | Yes | Yes | Cloudflare Pages | +| `cloudflare` | Workers | Yes | No | Cloudflare Workers | +| `netlify` | Node | Yes | Yes | Netlify hosting | +| `node-server` | Node | No | No | Docker, VPS, self-host | +| `static` | None | No | Yes | Any static host | +| `aws-lambda` | Node | No | No | AWS serverless | +| `bun` | Bun | No | No | Bun runtime | + +## Context + +- Adapters transform output for target platform +- Edge adapters have API limitations (no file system, etc.) +- Static preset requires all routes to be prerenderable +- Test locally with `npm run build && npm run preview` +- Check platform docs for runtime-specific constraints +- Some platforms auto-detect TanStack Start (no adapter needed) diff --git a/.agents/skills/tanstack-start-best-practices/rules/env-functions.md b/.agents/skills/tanstack-start-best-practices/rules/env-functions.md new file mode 100644 index 0000000..a75841e --- /dev/null +++ b/.agents/skills/tanstack-start-best-practices/rules/env-functions.md @@ -0,0 +1,211 @@ +# env-functions: Use Environment Functions for Configuration + +## Priority: MEDIUM + +## Explanation + +Environment functions provide type-safe access to environment variables on the server. They ensure secrets stay server-side, provide validation, and enable different configurations per environment (development, staging, production). + +## Bad Example + +```tsx +// Accessing env vars directly - no validation, potential leaks +export const getApiData = createServerFn() + .handler(async () => { + // No validation - may be undefined + const apiKey = process.env.API_KEY + + // Accidentally exposed in error messages + if (!apiKey) { + throw new Error(`Missing API_KEY: ${process.env}`) + } + + return fetch(url, { headers: { Authorization: apiKey } }) + }) + +// Or importing env in shared files +// lib/config.ts +export const config = { + apiKey: process.env.API_KEY, // Bundled into client! + dbUrl: process.env.DATABASE_URL, +} +``` + +## Good Example: Validated Environment Configuration + +```tsx +// lib/env.server.ts +import { z } from 'zod' + +const envSchema = z.object({ + // Required + DATABASE_URL: z.string().url(), + SESSION_SECRET: z.string().min(32), + + // API Keys + STRIPE_SECRET_KEY: z.string().startsWith('sk_'), + STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_'), + + // Optional with defaults + NODE_ENV: z.enum(['development', 'staging', 'production']).default('development'), + LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'), + + // Optional + SENTRY_DSN: z.string().url().optional(), +}) + +export type Env = z.infer + +function validateEnv(): Env { + const parsed = envSchema.safeParse(process.env) + + if (!parsed.success) { + console.error('Invalid environment variables:') + console.error(parsed.error.flatten().fieldErrors) + throw new Error('Invalid environment configuration') + } + + return parsed.data +} + +// Validate once at startup +export const env = validateEnv() + +// Usage in server functions +export const getPaymentIntent = createServerFn({ method: 'POST' }) + .handler(async () => { + const stripe = new Stripe(env.STRIPE_SECRET_KEY) + // Type-safe, validated access + }) +``` + +## Good Example: Public vs Private Config + +```tsx +// lib/env.server.ts - Server only (secrets) +export const serverEnv = { + databaseUrl: process.env.DATABASE_URL!, + sessionSecret: process.env.SESSION_SECRET!, + stripeSecretKey: process.env.STRIPE_SECRET_KEY!, +} + +// lib/env.ts - Public config (safe for client) +export const publicEnv = { + appUrl: process.env.VITE_APP_URL ?? 'http://localhost:3000', + stripePublicKey: process.env.VITE_STRIPE_PUBLIC_KEY!, + sentryDsn: process.env.VITE_SENTRY_DSN, +} + +// Vite exposes VITE_ prefixed vars to client +// Non-prefixed vars are server-only +``` + +## Good Example: Environment-Specific Behavior + +```tsx +// lib/env.server.ts +export const env = validateEnv() + +export const isDevelopment = env.NODE_ENV === 'development' +export const isProduction = env.NODE_ENV === 'production' +export const isStaging = env.NODE_ENV === 'staging' + +// lib/logger.server.ts +import { env, isDevelopment } from './env.server' + +export function log(level: string, message: string, data?: unknown) { + if (isDevelopment) { + console.log(`[${level}]`, message, data) + return + } + + // Production: send to logging service + if (env.SENTRY_DSN) { + // Send to Sentry + } +} + +// Server function with environment checks +export const debugInfo = createServerFn() + .handler(async () => { + if (isProduction) { + throw new Error('Debug endpoint not available in production') + } + + return { + nodeVersion: process.version, + env: env.NODE_ENV, + } + }) +``` + +## Good Example: Feature Flags via Environment + +```tsx +// lib/features.server.ts +import { env } from './env.server' + +export const features = { + newCheckout: env.FEATURE_NEW_CHECKOUT === 'true', + betaDashboard: env.FEATURE_BETA_DASHBOARD === 'true', + aiAssistant: env.FEATURE_AI_ASSISTANT === 'true', +} + +// Usage in server functions +export const getCheckoutUrl = createServerFn() + .handler(async () => { + if (features.newCheckout) { + return '/checkout/v2' + } + return '/checkout' + }) + +// Usage in loaders +export const Route = createFileRoute('/dashboard')({ + loader: async () => { + return { + showBetaFeatures: features.betaDashboard, + } + }, +}) +``` + +## Good Example: Type-Safe env.d.ts + +```tsx +// env.d.ts - TypeScript declarations for env vars +declare namespace NodeJS { + interface ProcessEnv { + // Required + DATABASE_URL: string + SESSION_SECRET: string + + // Optional + NODE_ENV?: 'development' | 'staging' | 'production' + SENTRY_DSN?: string + + // Vite public vars + VITE_APP_URL?: string + VITE_STRIPE_PUBLIC_KEY: string + } +} +``` + +## Environment Variable Checklist + +| Variable | Prefix | Accessible On | +|----------|--------|---------------| +| `DATABASE_URL` | None | Server only | +| `SESSION_SECRET` | None | Server only | +| `STRIPE_SECRET_KEY` | None | Server only | +| `VITE_APP_URL` | `VITE_` | Server + Client | +| `VITE_STRIPE_PUBLIC_KEY` | `VITE_` | Server + Client | + +## Context + +- Never import `.server.ts` files in client code +- Use `VITE_` prefix for client-accessible variables +- Validate at startup to fail fast on misconfiguration +- Use Zod or similar for runtime validation +- Keep secrets out of error messages and logs +- Consider using `.env.local` for local overrides (gitignored) diff --git a/.agents/skills/tanstack-start-best-practices/rules/err-server-errors.md b/.agents/skills/tanstack-start-best-practices/rules/err-server-errors.md new file mode 100644 index 0000000..89d2889 --- /dev/null +++ b/.agents/skills/tanstack-start-best-practices/rules/err-server-errors.md @@ -0,0 +1,187 @@ +# err-server-errors: Handle Server Function Errors + +## Priority: MEDIUM + +## Explanation + +Server function errors cross the network boundary. Handle them gracefully with appropriate error types, status codes, and user-friendly messages. Avoid exposing internal details in production. + +## Bad Example + +```tsx +// Throwing raw errors - exposes internals +export const createUser = createServerFn({ method: 'POST' }) + .validator(createUserSchema) + .handler(async ({ data }) => { + const user = await db.users.create({ data }) // May throw DB error + return user + // Prisma error with stack trace sent to client + }) + +// Generic error handling - no useful info for client +export const getPost = createServerFn() + .handler(async ({ data }) => { + try { + return await fetchPost(data.id) + } catch (e) { + throw new Error('Something went wrong') // Too vague + } + }) +``` + +## Good Example: Structured Error Handling + +```tsx +// lib/errors.ts +export class AppError extends Error { + constructor( + message: string, + public code: string, + public status: number = 400 + ) { + super(message) + this.name = 'AppError' + } +} + +export class NotFoundError extends AppError { + constructor(resource: string) { + super(`${resource} not found`, 'NOT_FOUND', 404) + } +} + +export class UnauthorizedError extends AppError { + constructor(message = 'Unauthorized') { + super(message, 'UNAUTHORIZED', 401) + } +} + +export class ValidationError extends AppError { + constructor(message: string, public fields?: Record) { + super(message, 'VALIDATION_ERROR', 400) + } +} +``` + +## Good Example: Server Function with Error Handling + +```tsx +import { createServerFn, notFound } from '@tanstack/react-start' +import { setResponseStatus } from '@tanstack/react-start/server' + +export const getPost = createServerFn() + .validator(z.object({ id: z.string() })) + .handler(async ({ data }) => { + const post = await db.posts.findUnique({ + where: { id: data.id }, + }) + + if (!post) { + // Use built-in notFound for 404s + throw notFound() + } + + return post + }) + +export const createPost = createServerFn({ method: 'POST' }) + .validator(createPostSchema) + .handler(async ({ data }) => { + try { + const post = await db.posts.create({ data }) + return post + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (error.code === 'P2002') { + // Unique constraint violation + setResponseStatus(409) + throw new AppError('A post with this title already exists', 'DUPLICATE', 409) + } + } + + // Log full error server-side + console.error('Failed to create post:', error) + + // Return sanitized error to client + setResponseStatus(500) + throw new AppError('Failed to create post', 'INTERNAL_ERROR', 500) + } + }) +``` + +## Good Example: Client-Side Error Handling + +```tsx +function CreatePostForm() { + const [error, setError] = useState(null) + + const createMutation = useMutation({ + mutationFn: createPost, + onError: (error) => { + if (error instanceof AppError) { + setError(error.message) + } else if (error instanceof ValidationError) { + // Handle field-specific errors + Object.entries(error.fields ?? {}).forEach(([field, message]) => { + form.setError(field, { message }) + }) + } else { + setError('An unexpected error occurred') + } + }, + onSuccess: (post) => { + navigate({ to: '/posts/$postId', params: { postId: post.id } }) + }, + }) + + return ( +
+ {error && {error}} + {/* form fields */} +
+ ) +} +``` + +## Good Example: Using Redirects for Auth Errors + +```tsx +export const updateProfile = createServerFn({ method: 'POST' }) + .validator(updateProfileSchema) + .handler(async ({ data }) => { + const session = await getSessionData() + + if (!session) { + // Redirect to login for auth errors + throw redirect({ + to: '/login', + search: { redirect: '/settings' }, + }) + } + + return await db.users.update({ + where: { id: session.userId }, + data, + }) + }) +``` + +## Error Response Best Practices + +| Scenario | HTTP Status | Response | +|----------|-------------|----------| +| Validation failed | 400 | Field-specific errors | +| Not authenticated | 401 | Redirect to login | +| Not authorized | 403 | Generic forbidden message | +| Resource not found | 404 | Use `notFound()` | +| Conflict (duplicate) | 409 | Specific conflict message | +| Server error | 500 | Generic message, log details | + +## Context + +- Use `notFound()` for 404 errors - integrates with router +- Use `redirect()` for auth-related errors +- Set status codes with `setResponseStatus()` +- Log full errors server-side, sanitize for client +- Create custom error classes for consistent handling +- Validation errors from `.validator()` are automatic diff --git a/.agents/skills/tanstack-start-best-practices/rules/file-separation.md b/.agents/skills/tanstack-start-best-practices/rules/file-separation.md new file mode 100644 index 0000000..14646c1 --- /dev/null +++ b/.agents/skills/tanstack-start-best-practices/rules/file-separation.md @@ -0,0 +1,152 @@ +# file-separation: Separate Server and Client Code + +## Priority: LOW + +## Explanation + +Organize code by execution context to prevent server code from accidentally bundling into client builds. Use `.server.ts` for server-only code, `.functions.ts` for server function definitions, and standard `.ts` for shared code. + +## Bad Example + +```tsx +// lib/posts.ts - Mixed server and client code +import { db } from './db' // Database - server only +import { formatDate } from './utils' // Utility - shared + +export async function getPosts() { + // This uses db, so it's server-only + // But file might be imported on client + return db.posts.findMany() +} + +export function formatPostDate(date: Date) { + // This could run anywhere + return formatDate(date) +} + +// routes/posts.tsx +import { getPosts, formatPostDate } from '@/lib/posts' +// Importing getPosts pulls db into client bundle (error or bloat) +``` + +## Good Example: Clear Separation + +``` +lib/ +├── posts.ts # Shared types and utilities +├── posts.server.ts # Server-only database logic +├── posts.functions.ts # Server function definitions +└── schemas/ + └── post.ts # Shared validation schemas +``` + +```tsx +// lib/posts.ts - Shared (safe to import anywhere) +export interface Post { + id: string + title: string + content: string + createdAt: Date +} + +export function formatPostDate(date: Date): string { + return new Intl.DateTimeFormat('en-US', { + dateStyle: 'medium', + }).format(date) +} + +// lib/posts.server.ts - Server only (never import on client) +import { db } from './db' +import type { Post } from './posts' + +export async function getPostsFromDb(): Promise { + return db.posts.findMany({ + orderBy: { createdAt: 'desc' }, + }) +} + +export async function createPostInDb(data: CreatePostInput): Promise { + return db.posts.create({ data }) +} + +// lib/posts.functions.ts - Server functions (safe to import anywhere) +import { createServerFn } from '@tanstack/react-start' +import { getPostsFromDb, createPostInDb } from './posts.server' +import { createPostSchema } from './schemas/post' + +export const getPosts = createServerFn() + .handler(async () => { + return await getPostsFromDb() + }) + +export const createPost = createServerFn({ method: 'POST' }) + .validator(createPostSchema) + .handler(async ({ data }) => { + return await createPostInDb(data) + }) +``` + +## Good Example: Using in Components + +```tsx +// components/PostList.tsx +import { getPosts } from '@/lib/posts.functions' // Safe - RPC stub on client +import { formatPostDate } from '@/lib/posts' // Safe - shared utility +import type { Post } from '@/lib/posts' // Safe - type only + +function PostList() { + const postsQuery = useQuery({ + queryKey: ['posts'], + queryFn: () => getPosts(), // Calls server function + }) + + return ( +
    + {postsQuery.data?.map((post) => ( +
  • + {post.title} + {formatPostDate(post.createdAt)} +
  • + ))} +
+ ) +} +``` + +## File Convention Summary + +| Suffix | Purpose | Safe to Import on Client | +|--------|---------|-------------------------| +| `.ts` | Shared utilities, types | Yes | +| `.server.ts` | Server-only logic (db, secrets) | No | +| `.functions.ts` | Server function wrappers | Yes | +| `.client.ts` | Client-only code | Yes (client only) | + +## Good Example: Environment Variables + +```tsx +// lib/config.server.ts - Server secrets +export const config = { + databaseUrl: process.env.DATABASE_URL!, + sessionSecret: process.env.SESSION_SECRET!, + stripeSecretKey: process.env.STRIPE_SECRET_KEY!, +} + +// lib/config.ts - Public config (safe for client) +export const publicConfig = { + appName: 'My App', + apiUrl: process.env.NEXT_PUBLIC_API_URL, + stripePublicKey: process.env.NEXT_PUBLIC_STRIPE_KEY, +} + +// Never import config.server.ts on client +``` + +## Context + +- `.server.ts` files should never be directly imported in client code +- Server functions in `.functions.ts` are safe - build replaces with RPC +- Types from `.server.ts` are safe if using `import type` +- TanStack Start's build process validates proper separation +- This pattern enables tree-shaking and smaller client bundles +- Use consistent naming convention across your team diff --git a/.agents/skills/tanstack-start-best-practices/rules/mw-request-middleware.md b/.agents/skills/tanstack-start-best-practices/rules/mw-request-middleware.md new file mode 100644 index 0000000..a6d6c9d --- /dev/null +++ b/.agents/skills/tanstack-start-best-practices/rules/mw-request-middleware.md @@ -0,0 +1,166 @@ +# mw-request-middleware: Use Request Middleware for Cross-Cutting Concerns + +## Priority: HIGH + +## Explanation + +Request middleware runs before every server request (routes, SSR, server functions). Use it for authentication, logging, rate limiting, and other cross-cutting concerns that apply globally. + +## Bad Example + +```tsx +// Duplicating auth logic in every server function +export const getProfile = createServerFn() + .handler(async () => { + const session = await getSession() + if (!session) throw new Error('Unauthorized') + // ... rest of handler + }) + +export const updateProfile = createServerFn({ method: 'POST' }) + .handler(async ({ data }) => { + const session = await getSession() + if (!session) throw new Error('Unauthorized') + // ... rest of handler + }) + +export const deleteAccount = createServerFn({ method: 'POST' }) + .handler(async () => { + const session = await getSession() + if (!session) throw new Error('Unauthorized') + // ... rest of handler + }) +``` + +## Good Example: Authentication Middleware + +```tsx +// lib/middleware/auth.ts +import { createMiddleware } from '@tanstack/react-start' +import { getSession } from './session.server' + +export const authMiddleware = createMiddleware() + .server(async ({ next }) => { + const session = await getSession() + + // Pass session to downstream handlers via context + return next({ + context: { + session, + user: session?.user ?? null, + }, + }) + }) + +// lib/middleware/requireAuth.ts +export const requireAuthMiddleware = createMiddleware() + .middleware([authMiddleware]) // Depends on auth middleware + .server(async ({ next, context }) => { + if (!context.user) { + throw redirect({ to: '/login' }) + } + + return next({ + context: { + user: context.user, // Now guaranteed to exist + }, + }) + }) +``` + +## Good Example: Logging Middleware + +```tsx +// lib/middleware/logging.ts +export const loggingMiddleware = createMiddleware() + .server(async ({ next, request }) => { + const start = Date.now() + const requestId = crypto.randomUUID() + + console.log(`[${requestId}] ${request.method} ${request.url}`) + + try { + const result = await next({ + context: { requestId }, + }) + + console.log(`[${requestId}] Completed in ${Date.now() - start}ms`) + return result + } catch (error) { + console.error(`[${requestId}] Error:`, error) + throw error + } + }) +``` + +## Good Example: Global Middleware Configuration + +```tsx +// app/start.ts +import { createStart } from '@tanstack/react-start/server' +import { loggingMiddleware } from './middleware/logging' +import { authMiddleware } from './middleware/auth' + +export default createStart({ + // Request middleware runs for all requests + requestMiddleware: [ + loggingMiddleware, + authMiddleware, + ], +}) +``` + +## Good Example: Rate Limiting Middleware + +```tsx +// lib/middleware/rateLimit.ts +import { createMiddleware } from '@tanstack/react-start' + +const rateLimitStore = new Map() + +export const rateLimitMiddleware = createMiddleware() + .server(async ({ next, request }) => { + const ip = request.headers.get('x-forwarded-for') ?? 'unknown' + const now = Date.now() + const windowMs = 60 * 1000 // 1 minute + const maxRequests = 100 + + let record = rateLimitStore.get(ip) + + if (!record || record.resetAt < now) { + record = { count: 0, resetAt: now + windowMs } + } + + record.count++ + rateLimitStore.set(ip, record) + + if (record.count > maxRequests) { + throw new Response('Too Many Requests', { status: 429 }) + } + + return next() + }) +``` + +## Middleware Execution Order + +``` +Request → Middleware 1 → Middleware 2 → Handler → Middleware 2 → Middleware 1 → Response + +// Example with timing: +loggingMiddleware.server(async ({ next }) => { + console.log('Before handler') + const result = await next() // Calls next middleware/handler + console.log('After handler') + return result +}) +``` + +## Context + +- Request middleware applies to all server requests +- Middleware can add to context using `next({ context: {...} })` +- Order matters - first middleware wraps the entire chain +- Global middleware defined in `app/start.ts` +- Route-specific middleware uses `beforeLoad` +- Server function middleware uses separate pattern (see `mw-function-middleware`) diff --git a/.agents/skills/tanstack-start-best-practices/rules/sf-create-server-fn.md b/.agents/skills/tanstack-start-best-practices/rules/sf-create-server-fn.md new file mode 100644 index 0000000..b7c975f --- /dev/null +++ b/.agents/skills/tanstack-start-best-practices/rules/sf-create-server-fn.md @@ -0,0 +1,146 @@ +# sf-create-server-fn: Use createServerFn for Server-Side Logic + +## Priority: CRITICAL + +## Explanation + +`createServerFn()` creates type-safe server functions that can be called from anywhere - loaders, components, or other server functions. The code inside the handler runs only on the server, with automatic RPC for client calls. + +## Bad Example + +```tsx +// Using fetch directly - no type safety, manual serialization +async function createPost(data: CreatePostInput) { + const response = await fetch('/api/posts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + if (!response.ok) throw new Error('Failed to create post') + return response.json() +} + +// Or using API routes - more boilerplate +// api/posts.ts +export async function POST(request: Request) { + const data = await request.json() + // No type safety from client + const post = await db.posts.create({ data }) + return new Response(JSON.stringify(post)) +} +``` + +## Good Example + +```tsx +// lib/posts.functions.ts +import { createServerFn } from '@tanstack/react-start' +import { z } from 'zod' +import { db } from './db.server' + +const createPostSchema = z.object({ + title: z.string().min(1).max(200), + content: z.string().min(1), + published: z.boolean().default(false), +}) + +export const createPost = createServerFn({ method: 'POST' }) + .validator(createPostSchema) + .handler(async ({ data }) => { + // This code only runs on the server + const post = await db.posts.create({ + data: { + title: data.title, + content: data.content, + published: data.published, + }, + }) + return post + }) + +// Usage in component +function CreatePostForm() { + const createPostMutation = useServerFn(createPost) + + const handleSubmit = async (formData: FormData) => { + try { + const post = await createPostMutation({ + data: { + title: formData.get('title') as string, + content: formData.get('content') as string, + published: false, + }, + }) + // post is fully typed + console.log('Created post:', post.id) + } catch (error) { + console.error('Failed to create post:', error) + } + } +} +``` + +## Good Example: GET Function for Data Fetching + +```tsx +// lib/posts.functions.ts +export const getPosts = createServerFn() // GET is default + .handler(async () => { + const posts = await db.posts.findMany({ + orderBy: { createdAt: 'desc' }, + take: 20, + }) + return posts + }) + +export const getPost = createServerFn() + .validator(z.object({ id: z.string() })) + .handler(async ({ data }) => { + const post = await db.posts.findUnique({ + where: { id: data.id }, + }) + if (!post) { + throw notFound() + } + return post + }) + +// Usage in route loader +export const Route = createFileRoute('/posts/$postId')({ + loader: async ({ params }) => { + return await getPost({ data: { id: params.postId } }) + }, +}) +``` + +## Good Example: With Context and Dependencies + +```tsx +// Compose server functions +export const getPostWithComments = createServerFn() + .validator(z.object({ postId: z.string() })) + .handler(async ({ data }) => { + const [post, comments] = await Promise.all([ + getPost({ data: { id: data.postId } }), + getComments({ data: { postId: data.postId } }), + ]) + + return { post, comments } + }) +``` + +## Key Benefits + +- **Type safety**: Input/output types flow through client and server +- **Automatic serialization**: No manual JSON parsing +- **Code splitting**: Server code never reaches client bundle +- **Composable**: Call from loaders, components, or other server functions +- **Validation**: Built-in input validation with schema libraries + +## Context + +- Default method is GET (idempotent, cacheable) +- Use POST for mutations that change data +- Server functions are RPC calls under the hood +- Validation errors are properly typed and serialized +- Import is safe on client - build process replaces with RPC stub diff --git a/.agents/skills/tanstack-start-best-practices/rules/sf-input-validation.md b/.agents/skills/tanstack-start-best-practices/rules/sf-input-validation.md new file mode 100644 index 0000000..b89ec6d --- /dev/null +++ b/.agents/skills/tanstack-start-best-practices/rules/sf-input-validation.md @@ -0,0 +1,158 @@ +# sf-input-validation: Always Validate Server Function Inputs + +## Priority: CRITICAL + +## Explanation + +Server functions receive data across the network boundary. Always validate inputs before processing - never trust client data. Use schema validation libraries like Zod for type-safe validation. + +## Bad Example + +```tsx +// No validation - trusting client input directly +export const updateUser = createServerFn({ method: 'POST' }) + .handler(async ({ data }) => { + // data is unknown/any - no type safety + // SQL injection, invalid data, type errors all possible + await db.users.update({ + where: { id: data.id }, + data: { + name: data.name, + email: data.email, + role: data.role, // Could be set to 'admin' by malicious client! + }, + }) + }) + +// Weak validation - type assertion without runtime check +export const deletePost = createServerFn({ method: 'POST' }) + .handler(async ({ data }: { data: { id: string } }) => { + // Type assertion doesn't validate at runtime + await db.posts.delete({ where: { id: data.id } }) + }) +``` + +## Good Example: With Zod Validation + +```tsx +import { createServerFn } from '@tanstack/react-start' +import { z } from 'zod' + +const updateUserSchema = z.object({ + id: z.string().uuid(), + name: z.string().min(1).max(100), + email: z.string().email(), + // Don't allow role updates from client input! +}) + +export const updateUser = createServerFn({ method: 'POST' }) + .validator(updateUserSchema) + .handler(async ({ data }) => { + // data is fully typed: { id: string; name: string; email: string } + const user = await db.users.update({ + where: { id: data.id }, + data: { + name: data.name, + email: data.email, + }, + }) + return user + }) + +// Validation errors are automatically returned to client +// with proper status codes and messages +``` + +## Good Example: Complex Validation + +```tsx +const createOrderSchema = z.object({ + items: z.array(z.object({ + productId: z.string().uuid(), + quantity: z.number().int().min(1).max(100), + })).min(1).max(50), + shippingAddress: z.object({ + street: z.string().min(1), + city: z.string().min(1), + state: z.string().length(2), + zip: z.string().regex(/^\d{5}(-\d{4})?$/), + }), + couponCode: z.string().optional(), +}) + +export const createOrder = createServerFn({ method: 'POST' }) + .validator(createOrderSchema) + .handler(async ({ data }) => { + // All data is validated and typed + // Process order safely + }) +``` + +## Good Example: Transform and Refine + +```tsx +const registrationSchema = z.object({ + email: z.string().email().toLowerCase(), // Transform to lowercase + password: z.string() + .min(8, 'Password must be at least 8 characters') + .regex(/[A-Z]/, 'Password must contain uppercase letter') + .regex(/[0-9]/, 'Password must contain number'), + confirmPassword: z.string(), +}).refine( + (data) => data.password === data.confirmPassword, + { message: 'Passwords must match', path: ['confirmPassword'] } +) + +export const register = createServerFn({ method: 'POST' }) + .validator(registrationSchema) + .handler(async ({ data }) => { + // Passwords match, email is lowercase + // Only password needed (confirmPassword was for validation) + const hashedPassword = await hashPassword(data.password) + return await createUser({ + email: data.email, + password: hashedPassword, + }) + }) +``` + +## Sharing Schemas Between Client and Server + +```tsx +// lib/schemas/post.ts - Shared validation schema +import { z } from 'zod' + +export const createPostSchema = z.object({ + title: z.string().min(1).max(200), + content: z.string().min(1), + tags: z.array(z.string()).max(10).optional(), +}) + +export type CreatePostInput = z.infer + +// lib/posts.functions.ts - Server function +import { createPostSchema } from './schemas/post' + +export const createPost = createServerFn({ method: 'POST' }) + .validator(createPostSchema) + .handler(async ({ data }) => { /* ... */ }) + +// components/CreatePostForm.tsx - Client form validation +import { createPostSchema, type CreatePostInput } from '@/lib/schemas/post' + +function CreatePostForm() { + const form = useForm({ + resolver: zodResolver(createPostSchema), + }) + // Same validation client and server side +} +``` + +## Context + +- Network boundary = trust boundary - always validate +- Use `.validator()` before `.handler()` in the chain +- Validation errors return proper HTTP status codes +- Share schemas between client forms and server functions +- Strip or ignore fields clients shouldn't control (like `role`, `isAdmin`) +- Consider rate limiting for mutation endpoints diff --git a/.agents/skills/tanstack-start-best-practices/rules/ssr-hydration-safety.md b/.agents/skills/tanstack-start-best-practices/rules/ssr-hydration-safety.md new file mode 100644 index 0000000..330933c --- /dev/null +++ b/.agents/skills/tanstack-start-best-practices/rules/ssr-hydration-safety.md @@ -0,0 +1,187 @@ +# ssr-hydration-safety: Prevent Hydration Mismatches + +## Priority: MEDIUM + +## Explanation + +Hydration errors occur when server-rendered HTML doesn't match what the client expects. This causes React to discard server HTML and re-render, losing SSR benefits. Ensure consistent rendering between server and client. + +## Bad Example + +```tsx +// Using Date.now() - different on server and client +function Timestamp() { + return Generated at: {Date.now()} +} + +// Using Math.random() - always different +function RandomGreeting() { + const greetings = ['Hello', 'Hi', 'Hey'] + return

{greetings[Math.floor(Math.random() * 3)]}

+} + +// Checking window - doesn't exist on server +function DeviceInfo() { + return Width: {window.innerWidth}px // Error on server +} + +// Conditional render based on time +function TimeBasedContent() { + const hour = new Date().getHours() + return hour < 12 ? : + // Server might render Morning, client renders Evening +} +``` + +## Good Example: Consistent Server/Client Rendering + +```tsx +// Pass data from server to avoid mismatch +export const Route = createFileRoute('/dashboard')({ + loader: async () => { + return { + generatedAt: Date.now(), + } + }, + component: Dashboard, +}) + +function Dashboard() { + const { generatedAt } = Route.useLoaderData() + // Both server and client use same value + return Generated at: {generatedAt} +} +``` + +## Good Example: Client-Only Components + +```tsx +// Use lazy loading for client-only features +import { lazy, Suspense } from 'react' + +const ClientOnlyMap = lazy(() => import('./Map')) + +function LocationPage() { + return ( +
+

Our Location

+ }> + + +
+ ) +} + +// Or use useEffect for client-only state +function WindowSize() { + const [size, setSize] = useState<{ width: number; height: number } | null>(null) + + useEffect(() => { + setSize({ + width: window.innerWidth, + height: window.innerHeight, + }) + }, []) + + if (!size) { + return Loading dimensions... + } + + return {size.width} x {size.height} +} +``` + +## Good Example: Stable Random Values + +```tsx +// Generate random value on server, pass to client +export const Route = createFileRoute('/onboarding')({ + loader: () => ({ + welcomeVariant: Math.floor(Math.random() * 3), + }), + component: Onboarding, +}) + +function Onboarding() { + const { welcomeVariant } = Route.useLoaderData() + const messages = ['Welcome aboard!', 'Let's get started!', 'Great to have you!'] + + return

{messages[welcomeVariant]}

// Same on server and client +} +``` + +## Good Example: Handling Time Zones + +```tsx +// Pass formatted date from server +export const Route = createFileRoute('/posts/$postId')({ + loader: async ({ params }) => { + const post = await getPost(params.postId) + return { + ...post, + // Format on server to avoid timezone mismatch + formattedDate: new Intl.DateTimeFormat('en-US', { + dateStyle: 'long', + timeStyle: 'short', + timeZone: 'UTC', // Consistent timezone + }).format(post.createdAt), + } + }, + component: PostPage, +}) + +// Or use client-only formatting +function RelativeTime({ date }: { date: Date }) { + const [formatted, setFormatted] = useState('') + + useEffect(() => { + // Format in user's timezone after hydration + setFormatted(formatDistanceToNow(date, { addSuffix: true })) + }, [date]) + + // Show absolute date initially (same server/client) + return +} +``` + +## Common Hydration Mismatch Causes + +| Issue | Solution | +|-------|----------| +| `Date.now()` / `new Date()` | Pass timestamp from loader | +| `Math.random()` | Generate on server, pass to client | +| `window` / `document` | Use useEffect or lazy loading | +| User timezone differences | Use UTC or client-only formatting | +| Browser-specific APIs | Check `typeof window !== 'undefined'` | +| Extension-injected content | Use `suppressHydrationWarning` | + +## Debugging Hydration Errors + +```tsx +// React 18+ provides detailed hydration error messages +// Check the console for: +// - "Text content does not match" +// - "Hydration failed because" +// - The specific DOM element causing the issue + +// For difficult cases, use suppressHydrationWarning sparingly +function UserContent({ html }: { html: string }) { + return ( +
+ ) +} +``` + +## Context + +- Hydration compares server HTML with client render +- Mismatches force full client re-render (slow, flash) +- Use loaders to pass dynamic data consistently +- Defer client-only content with useEffect or Suspense +- Test SSR by disabling JavaScript and checking render +- Development mode shows hydration warnings in console diff --git a/.agents/skills/tanstack-start-best-practices/rules/ssr-prerender.md b/.agents/skills/tanstack-start-best-practices/rules/ssr-prerender.md new file mode 100644 index 0000000..e795218 --- /dev/null +++ b/.agents/skills/tanstack-start-best-practices/rules/ssr-prerender.md @@ -0,0 +1,199 @@ +# ssr-prerender: Configure Static Prerendering and ISR + +## Priority: MEDIUM + +## Explanation + +Static prerendering generates HTML at build time for pages that don't require request-time data. Incremental Static Regeneration (ISR) extends this by revalidating cached pages on a schedule. Use these for better performance and lower server costs. + +## Bad Example + +```tsx +// SSR for completely static content - wasteful +export const Route = createFileRoute('/about')({ + loader: async () => { + // Fetching static content on every request + const content = await fetchAboutPageContent() + return { content } + }, +}) + +// Or no caching headers for semi-static content +export const Route = createFileRoute('/blog/$slug')({ + loader: async ({ params }) => { + const post = await fetchPost(params.slug) + return { post } + // Every request hits the database + }, +}) +``` + +## Good Example: Static Prerendering + +```tsx +// app.config.ts +import { defineConfig } from '@tanstack/react-start/config' + +export default defineConfig({ + server: { + prerender: { + // Routes to prerender at build time + routes: [ + '/', + '/about', + '/contact', + '/pricing', + ], + // Or crawl from root + crawlLinks: true, + }, + }, +}) + +// routes/about.tsx - Will be prerendered +export const Route = createFileRoute('/about')({ + loader: async () => { + // Runs at BUILD time, not request time + const content = await fetchAboutPageContent() + return { content } + }, + component: AboutPage, +}) +``` + +## Good Example: Dynamic Prerendering + +```tsx +// app.config.ts +export default defineConfig({ + server: { + prerender: { + // Generate routes dynamically + routes: async () => { + const posts = await db.posts.findMany({ + where: { published: true }, + select: { slug: true }, + }) + + return [ + '/', + '/blog', + ...posts.map(p => `/blog/${p.slug}`), + ] + }, + }, + }, +}) +``` + +## Good Example: ISR with Revalidation + +```tsx +// routes/blog/$slug.tsx +import { createFileRoute } from '@tanstack/react-router' +import { setHeaders } from '@tanstack/react-start/server' + +export const Route = createFileRoute('/blog/$slug')({ + loader: async ({ params }) => { + const post = await fetchPost(params.slug) + + // ISR: Cache for 60 seconds, then revalidate + setHeaders({ + 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300', + }) + + return { post } + }, + component: BlogPost, +}) + +// First request: SSR and cache +// Next 60 seconds: Serve cached version +// After 60 seconds: Serve stale, revalidate in background +// After 300 seconds: Full SSR again +``` + +## Good Example: Hybrid Static/Dynamic + +```tsx +// routes/products.tsx - Prerendered +export const Route = createFileRoute('/products')({ + loader: async () => { + // Featured products - prerendered at build + const featured = await fetchFeaturedProducts() + return { featured } + }, +}) + +// routes/products/$productId.tsx - ISR +export const Route = createFileRoute('/products/$productId')({ + loader: async ({ params }) => { + const product = await fetchProduct(params.productId) + + if (!product) throw notFound() + + // Cache product pages for 5 minutes + setHeaders({ + 'Cache-Control': 'public, s-maxage=300, stale-while-revalidate=600', + }) + + return { product } + }, +}) + +// routes/cart.tsx - Always SSR (user-specific) +export const Route = createFileRoute('/cart')({ + loader: async ({ context }) => { + // No caching - user-specific data + setHeaders({ + 'Cache-Control': 'private, no-store', + }) + + const cart = await fetchUserCart(context.user.id) + return { cart } + }, +}) +``` + +## Good Example: On-Demand Revalidation + +```tsx +// API route to trigger revalidation +// app/routes/api/revalidate.ts +export const APIRoute = createAPIFileRoute('/api/revalidate')({ + POST: async ({ request }) => { + const { secret, path } = await request.json() + + // Verify secret + if (secret !== process.env.REVALIDATE_SECRET) { + return json({ error: 'Invalid secret' }, { status: 401 }) + } + + // Trigger revalidation (implementation depends on hosting) + await revalidatePath(path) + + return json({ revalidated: true, path }) + }, +}) + +// Usage: POST /api/revalidate { "secret": "...", "path": "/blog/my-post" } +``` + +## Cache-Control Directives + +| Directive | Meaning | +|-----------|---------| +| `s-maxage=N` | CDN cache duration (seconds) | +| `max-age=N` | Browser cache duration | +| `stale-while-revalidate=N` | Serve stale while fetching fresh | +| `private` | Don't cache on CDN (user-specific) | +| `no-store` | Never cache | + +## Context + +- Prerendering happens at build time - no request context +- ISR requires CDN/edge support (Vercel, Cloudflare, etc.) +- Use prerendering for truly static pages (about, pricing) +- Use ISR for content that changes but not per-request +- Always SSR for user-specific or real-time data +- Test with production builds - dev server is always SSR diff --git a/.agents/skills/tanstack-start-best-practices/rules/ssr-streaming.md b/.agents/skills/tanstack-start-best-practices/rules/ssr-streaming.md new file mode 100644 index 0000000..95c3484 --- /dev/null +++ b/.agents/skills/tanstack-start-best-practices/rules/ssr-streaming.md @@ -0,0 +1,201 @@ +# ssr-streaming: Implement Streaming SSR for Faster TTFB + +## Priority: MEDIUM + +## Explanation + +Streaming SSR sends HTML chunks to the browser as they're ready, rather than waiting for all data to load. This improves Time to First Byte (TTFB) and perceived performance by showing content progressively. + +## Bad Example + +```tsx +// Blocking SSR - waits for everything +export const Route = createFileRoute('/dashboard')({ + loader: async ({ context: { queryClient } }) => { + // All of these must complete before ANY HTML is sent + await Promise.all([ + queryClient.ensureQueryData(userQueries.profile()), // 200ms + queryClient.ensureQueryData(dashboardQueries.stats()), // 500ms + queryClient.ensureQueryData(activityQueries.recent()), // 300ms + queryClient.ensureQueryData(notificationQueries.all()), // 400ms + ]) + // TTFB: 500ms (slowest query) + }, +}) +``` + +## Good Example: Stream Non-Critical Content + +```tsx +// routes/dashboard.tsx +export const Route = createFileRoute('/dashboard')({ + loader: async ({ context: { queryClient } }) => { + // Only await critical above-the-fold data + await queryClient.ensureQueryData(userQueries.profile()) + + // Start fetching but don't await + queryClient.prefetchQuery(dashboardQueries.stats()) + queryClient.prefetchQuery(activityQueries.recent()) + queryClient.prefetchQuery(notificationQueries.all()) + + // HTML starts streaming immediately after profile loads + // TTFB: 200ms + }, + component: DashboardPage, +}) + +function DashboardPage() { + // Critical data - ready immediately (from loader) + const { data: user } = useSuspenseQuery(userQueries.profile()) + + return ( +
+
+ + {/* Non-critical - streams in with Suspense */} + }> + + + + }> + + + + }> + + +
+ ) +} + +// Each section loads independently and streams when ready +function DashboardStats() { + const { data: stats } = useSuspenseQuery(dashboardQueries.stats()) + return +} +``` + +## Good Example: Nested Suspense Boundaries + +```tsx +function DashboardPage() { + const { data: user } = useSuspenseQuery(userQueries.profile()) + + return ( +
+
+ +
+ {/* Left column streams together */} + }> + + + + {/* Right column streams independently */} + }> + + +
+
+ ) +} + +function LeftColumn() { + // These load together (same Suspense boundary) + const { data: stats } = useSuspenseQuery(dashboardQueries.stats()) + const { data: chart } = useSuspenseQuery(dashboardQueries.chartData()) + + return ( +
+ + +
+ ) +} +``` + +## Good Example: Progressive Enhancement + +```tsx +export const Route = createFileRoute('/posts/$postId')({ + loader: async ({ params, context: { queryClient } }) => { + // Critical: post content (await) + await queryClient.ensureQueryData(postQueries.detail(params.postId)) + + // Start but don't block: comments, related posts + queryClient.prefetchQuery(commentQueries.forPost(params.postId)) + queryClient.prefetchQuery(postQueries.related(params.postId)) + }, + component: PostPage, +}) + +function PostPage() { + const { postId } = Route.useParams() + const { data: post } = useSuspenseQuery(postQueries.detail(postId)) + + return ( +
+ {/* Streams immediately */} + + + + {/* Streams when ready */} + }> + + + + }> + + +
+ ) +} +``` + +## Good Example: Error Boundaries with Streaming + +```tsx +function DashboardPage() { + return ( +
+
+ + {/* Each section handles its own errors */} + }> + }> + + + + + }> + }> + + + +
+ ) +} +``` + +## Streaming Timeline + +``` +Traditional SSR: +Request → [Wait for all data...] → Send complete HTML → Render + +Streaming SSR: +Request → Send shell HTML → Stream chunk 1 → Stream chunk 2 → Stream chunk 3 → Done + ↓ ↓ ↓ ↓ + Browser renders Shows content More content Complete + skeleton progressively +``` + +## Context + +- Suspense boundaries define streaming chunks +- Place boundaries around slow or non-critical content +- Critical path data should still be awaited in loader +- Each Suspense boundary can error independently +- Works with React 18's streaming SSR +- Monitor TTFB to verify streaming is working +- Consider network conditions - too many chunks can slow total load diff --git a/.agents/skills/vercel-react-best-practices/AGENTS.md b/.agents/skills/vercel-react-best-practices/AGENTS.md new file mode 100644 index 0000000..db951ab --- /dev/null +++ b/.agents/skills/vercel-react-best-practices/AGENTS.md @@ -0,0 +1,2934 @@ +# React Best Practices + +**Version 1.0.0** +Vercel Engineering +January 2026 + +> **Note:** +> This document is mainly for agents and LLMs to follow when maintaining, +> generating, or refactoring React and Next.js codebases. Humans +> may also find it useful, but guidance here is optimized for automation +> and consistency by AI-assisted workflows. + +--- + +## Abstract + +Comprehensive performance optimization guide for React and Next.js applications, designed for AI agents and LLMs. Contains 40+ rules across 8 categories, prioritized by impact from critical (eliminating waterfalls, reducing bundle size) to incremental (advanced patterns). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation. + +--- + +## 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) +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) +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) +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) + - 4.3 [Use SWR for Automatic Deduplication](#43-use-swr-for-automatic-deduplication) + - 4.4 [Version and Minimize localStorage Data](#44-version-and-minimize-localstorage-data) +5. [Re-render Optimization](#5-re-render-optimization) — **MEDIUM** + - 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) +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) + - 6.3 [Hoist Static JSX Elements](#63-hoist-static-jsx-elements) + - 6.4 [Optimize SVG Precision](#64-optimize-svg-precision) + - 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) +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) + - 7.3 [Cache Property Access in Loops](#73-cache-property-access-in-loops) + - 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) +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) + +--- + +## 1. Eliminating Waterfalls + +**Impact: CRITICAL** + +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 + +**Impact: HIGH (avoids blocking unused code paths)** + +Move `await` operations into the branches where they're actually used to avoid blocking code paths that don't need them. + +**Incorrect: blocks both branches** + +```typescript +async function handleRequest(userId: string, skipProcessing: boolean) { + const userData = await fetchUserData(userId) + + if (skipProcessing) { + // Returns immediately but still waited for userData + return { skipped: true } + } + + // Only this branch uses userData + return processUserData(userData) +} +``` + +**Correct: only blocks when needed** + +```typescript +async function handleRequest(userId: string, skipProcessing: boolean) { + if (skipProcessing) { + // Returns immediately without waiting + return { skipped: true } + } + + // Fetch only when needed + const userData = await fetchUserData(userId) + return processUserData(userData) +} +``` + +**Another example: early return optimization** + +```typescript +// Incorrect: always fetches permissions +async function updateResource(resourceId: string, userId: string) { + const permissions = await fetchPermissions(userId) + const resource = await getResource(resourceId) + + if (!resource) { + return { error: 'Not found' } + } + + if (!permissions.canEdit) { + return { error: 'Forbidden' } + } + + return await updateResourceData(resource, permissions) +} + +// Correct: fetches only when needed +async function updateResource(resourceId: string, userId: string) { + const resource = await getResource(resourceId) + + if (!resource) { + return { error: 'Not found' } + } + + const permissions = await fetchPermissions(userId) + + if (!permissions.canEdit) { + return { error: 'Forbidden' } + } + + 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 + +**Impact: CRITICAL (2-10× improvement)** + +For operations with partial dependencies, use `better-all` to maximize parallelism. It automatically starts each task at the earliest possible moment. + +**Incorrect: profile waits for config unnecessarily** + +```typescript +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' + +const { user, config, profile } = await all({ + async user() { return fetchUser() }, + async config() { return fetchConfig() }, + async profile() { + return fetchProfile((await this.$.user).id) + } +}) +``` + +**Alternative without extra dependencies:** + +```typescript +const userPromise = fetchUser() +const profilePromise = userPromise.then(user => fetchProfile(user.id)) + +const [user, config, profile] = await Promise.all([ + userPromise, + fetchConfig(), + 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 + +**Impact: CRITICAL (2-10× improvement)** + +In API routes and Server Actions, start independent operations immediately, even if you don't await them yet. + +**Incorrect: config waits for auth, data waits for both** + +```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 }) +} +``` + +**Correct: auth and config start immediately** + +```typescript +export async function GET(request: Request) { + 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 }) +} +``` + +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 + +**Impact: CRITICAL (2-10× improvement)** + +When async operations have no interdependencies, execute them concurrently using `Promise.all()`. + +**Incorrect: sequential execution, 3 round trips** + +```typescript +const user = await fetchUser() +const posts = await fetchPosts() +const comments = await fetchComments() +``` + +**Correct: parallel execution, 1 round trip** + +```typescript +const [user, posts, comments] = await Promise.all([ + fetchUser(), + fetchPosts(), + fetchComments() +]) +``` + +### 1.5 Strategic Suspense Boundaries + +**Impact: HIGH (faster initial paint)** + +Instead of awaiting data in async components before returning JSX, use Suspense boundaries to show the wrapper UI faster while data loads. + +**Incorrect: wrapper blocked by data fetching** + +```tsx +async function Page() { + const data = await fetchData() // Blocks entire page + + return ( +
+
Sidebar
+
Header
+
+ +
+
Footer
+
+ ) +} +``` + +The entire layout waits for data even though only the middle section needs it. + +**Correct: wrapper shows immediately, data streams in** + +```tsx +function Page() { + return ( +
+
Sidebar
+
Header
+
+ }> + + +
+
Footer
+
+ ) +} + +async function DataDisplay() { + const data = await fetchData() // Only blocks this component + return
{data.content}
+} +``` + +Sidebar, Header, and Footer render immediately. Only DataDisplay waits for data. + +**Alternative: share promise across components** + +```tsx +function Page() { + // Start fetch immediately, but don't await + const dataPromise = fetchData() + + return ( +
+
Sidebar
+
Header
+ }> + + + +
Footer
+
+ ) +} + +function DataDisplay({ dataPromise }: { dataPromise: Promise }) { + 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}
+} +``` + +Both components share the same promise, so only one fetch occurs. Layout renders immediately while both components wait together. + +**When NOT to use this pattern:** + +- Critical data needed for layout decisions (affects positioning) + +- SEO-critical content above the fold + +- Small, fast queries where suspense overhead isn't worth it + +- When you want to avoid layout shift (loading → content jump) + +**Trade-off:** Faster initial paint vs potential layout shift. Choose based on your UX priorities. + +--- + +## 2. Bundle Size Optimization + +**Impact: CRITICAL** + +Reducing initial bundle size improves Time to Interactive and Largest Contentful Paint. + +### 2.1 Avoid Barrel File Imports + +**Impact: CRITICAL (200-800ms import cost, slow builds)** + +Import directly from source files instead of barrel files to avoid loading thousands of unused modules. **Barrel files** are entry points that re-export multiple modules (e.g., `index.js` that does `export * from './module'`). + +Popular icon and component libraries can have **up to 10,000 re-exports** in their entry file. For many React packages, **it takes 200-800ms just to import them**, affecting both development speed and production cold starts. + +**Why tree-shaking doesn't help:** When a library is marked as external (not bundled), the bundler can't optimize it. If you bundle it to enable tree-shaking, builds become substantially slower analyzing the entire module graph. + +**Incorrect: imports entire library** + +```tsx +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' +// Loads 2,225 modules, takes ~4.2s extra in dev +``` + +**Correct: imports only what you need** + +```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 +``` + +**Alternative: Next.js 13.5+** + +```js +// next.config.js - use optimizePackageImports +module.exports = { + experimental: { + optimizePackageImports: ['lucide-react', '@mui/material'] + } +} + +// Then you can keep the ergonomic barrel imports: +import { Check, X, Menu } from 'lucide-react' +// Automatically transformed to direct imports at build time +``` + +Direct imports 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`. + +Reference: [https://vercel.com/blog/how-we-optimized-package-imports-in-next-js](https://vercel.com/blog/how-we-optimized-package-imports-in-next-js) + +### 2.2 Conditional Module Loading + +**Impact: HIGH (loads large data only when needed)** + +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) + + useEffect(() => { + if (enabled && !frames && typeof window !== 'undefined') { + import('./animation-frames.js') + .then(mod => setFrames(mod.frames)) + .catch(() => setEnabled(false)) + } + }, [enabled, frames, setEnabled]) + + if (!frames) return + return +} +``` + +The `typeof window !== 'undefined'` check prevents bundling this module for SSR, optimizing server bundle size and build speed. + +### 2.3 Defer Non-Critical Third-Party Libraries + +**Impact: MEDIUM (loads after hydration)** + +Analytics, logging, and error tracking don't block user interaction. Load them after hydration. + +**Incorrect: blocks initial bundle** + +```tsx +import { Analytics } from '@vercel/analytics/react' + +export default function RootLayout({ children }) { + return ( + + + {children} + + + + ) +} +``` + +**Correct: loads after hydration** + +```tsx +import dynamic from 'next/dynamic' + +const Analytics = dynamic( + () => import('@vercel/analytics/react').then(m => m.Analytics), + { ssr: false } +) + +export default function RootLayout({ children }) { + return ( + + + {children} + + + + ) +} +``` + +### 2.4 Dynamic Imports for Heavy Components + +**Impact: CRITICAL (directly affects TTI and LCP)** + +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' + +function CodePanel({ code }: { code: string }) { + return +} +``` + +**Correct: Monaco loads on demand** + +```tsx +import dynamic from 'next/dynamic' + +const MonacoEditor = dynamic( + () => import('./monaco-editor').then(m => m.MonacoEditor), + { ssr: false } +) + +function CodePanel({ code }: { code: string }) { + return +} +``` + +### 2.5 Preload Based on User Intent + +**Impact: MEDIUM (reduces perceived latency)** + +Preload heavy bundles before they're needed to reduce perceived latency. + +**Example: preload on hover/focus** + +```tsx +function EditorButton({ onClick }: { onClick: () => void }) { + const preload = () => { + if (typeof window !== 'undefined') { + void import('./monaco-editor') + } + } + + return ( + + ) +} +``` + +**Example: preload when feature flag is enabled** + +```tsx +function FlagsProvider({ children, flags }: Props) { + useEffect(() => { + if (flags.editorEnabled && typeof window !== 'undefined') { + void import('./monaco-editor').then(mod => mod.init()) + } + }, [flags.editorEnabled]) + + return + {children} + +} +``` + +The `typeof window !== 'undefined'` check prevents bundling preloaded modules for SSR, optimizing server bundle size and build speed. + +--- + +## 3. Server-Side Performance + +**Impact: HIGH** + +Optimizing server-side rendering and data fetching eliminates server-side waterfalls and reduces response times. + +### 3.1 Authenticate Server Actions Like API Routes + +**Impact: CRITICAL (prevents unauthorized access to server mutations)** + +Server Actions (functions with `"use server"`) are exposed as public endpoints, just like API routes. Always verify authentication and authorization **inside** each Server Action—do not rely solely on middleware, layout guards, or page-level checks, as Server Actions can be invoked directly. + +Next.js documentation explicitly states: "Treat Server Actions with the same security considerations as public-facing API endpoints, and verify if the user is allowed to perform a mutation." + +**Incorrect: no authentication check** + +```typescript +'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 } +} +``` + +**Correct: authentication inside the action** + +```typescript +'use server' + +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() + + if (!session) { + throw unauthorized('Must be logged in') + } + + // Check authorization too + 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 } +} +``` + +**With input validation:** + +```typescript +'use server' + +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() +}) + +export async function updateProfile(data: unknown) { + // Validate input first + const validated = updateProfileSchema.parse(data) + + // Then authenticate + const session = await verifySession() + if (!session) { + throw new Error('Unauthorized') + } + + // Then authorize + if (session.user.id !== validated.userId) { + 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 } +} +``` + +Reference: [https://nextjs.org/docs/app/guides/authentication](https://nextjs.org/docs/app/guides/authentication) + +### 3.2 Avoid Duplicate Serialization in RSC Props + +**Impact: LOW (reduces network payload by avoiding duplicate serialization)** + +RSC→client serialization deduplicates by object reference, not value. Same reference = serialized once; new reference = serialized again. Do transformations (`.toSorted()`, `.filter()`, `.map()`) in client, not server. + +**Incorrect: duplicates array** + +```tsx +// RSC: sends 6 strings (2 arrays × 3 items) + +``` + +**Correct: sends 3 strings** + +```tsx +// RSC: send once + + +// Client: transform there +'use client' +const sorted = useMemo(() => [...usernames].sort(), [usernames]) +``` + +**Nested deduplication behavior:** + +```tsx +// string[] - duplicates everything +usernames={['a','b']} sorted={usernames.toSorted()} // sends 4 strings + +// object[] - duplicates array structure only +users={[{id:1},{id:2}]} sorted={users.toSorted()} // sends 2 arrays + 2 unique objects (not 4) +``` + +Deduplication works recursively. Impact varies by data type: + +- `string[]`, `number[]`, `boolean[]`: **HIGH impact** - array + all primitives fully duplicated + +- `object[]`: **LOW impact** - array duplicated, but nested objects deduplicated by reference + +**Operations breaking deduplication: create new references** + +- Arrays: `.toSorted()`, `.filter()`, `.map()`, `.slice()`, `[...arr]` + +- Objects: `{...obj}`, `Object.assign()`, `structuredClone()`, `JSON.parse(JSON.stringify())` + +**More examples:** + +```tsx +// ❌ Bad + u.active)} /> + + +// ✅ Good + + +// Do filtering/destructuring in client +``` + +**Exception:** Pass derived data when transformation is expensive or client doesn't need original. + +### 3.3 Cross-Request LRU Caching + +**Impact: HIGH (caches across requests)** + +`React.cache()` only works within one request. For data shared across sequential requests (user clicks button A then button B), use an LRU cache. + +**Implementation:** + +```typescript +import { LRUCache } from 'lru-cache' + +const cache = new LRUCache({ + max: 1000, + ttl: 5 * 60 * 1000 // 5 minutes +}) + +export async function getUser(id: string) { + const cached = cache.get(id) + if (cached) return cached + + const user = await db.user.findUnique({ where: { id } }) + cache.set(id, user) + return user +} + +// Request 1: DB query, result cached +// Request 2: cache hit, no DB query +``` + +Use when sequential user actions hit multiple endpoints needing the same data within seconds. + +**With Vercel's [Fluid Compute](https://vercel.com/docs/fluid-compute):** LRU caching is especially effective because multiple concurrent requests can share the same function instance and cache. This means the cache persists across requests without needing external storage like Redis. + +**In traditional serverless:** Each invocation runs in isolation, so consider Redis for cross-process caching. + +Reference: [https://github.com/isaacs/node-lru-cache](https://github.com/isaacs/node-lru-cache) + +### 3.4 Minimize Serialization at RSC Boundaries + +**Impact: HIGH (reduces data transfer size)** + +The React Server/Client boundary serializes all object properties into strings and embeds them in the HTML response and subsequent RSC requests. This serialized data directly impacts page weight and load time, so **size matters a lot**. Only pass fields that the client actually uses. + +**Incorrect: serializes all 50 fields** + +```tsx +async function Page() { + const user = await fetchUser() // 50 fields + return +} + +'use client' +function Profile({ user }: { user: User }) { + return
{user.name}
// uses 1 field +} +``` + +**Correct: serializes only 1 field** + +```tsx +async function Page() { + const user = await fetchUser() + return +} + +'use client' +function Profile({ name }: { name: string }) { + return
{name}
+} +``` + +### 3.5 Parallel Data Fetching with Component Composition + +**Impact: CRITICAL (eliminates server-side waterfalls)** + +React Server Components execute sequentially within a tree. Restructure with composition to parallelize data fetching. + +**Incorrect: Sidebar waits for Page's fetch to complete** + +```tsx +export default async function Page() { + const header = await fetchHeader() + return ( +
+
{header}
+ +
+ ) +} + +async function Sidebar() { + const items = await fetchSidebarItems() + return +} +``` + +**Correct: both fetch simultaneously** + +```tsx +async function Header() { + const data = await fetchHeader() + return
{data}
+} + +async function Sidebar() { + const items = await fetchSidebarItems() + return +} + +export default function Page() { + return ( +
+
+ +
+ ) +} +``` + +**Alternative with children prop:** + +```tsx +async function Header() { + const data = await fetchHeader() + return
{data}
+} + +async function Sidebar() { + const items = await fetchSidebarItems() + return +} + +function Layout({ children }: { children: ReactNode }) { + return ( +
+
+ {children} +
+ ) +} + +export default function Page() { + return ( + + + + ) +} +``` + +### 3.6 Per-Request Deduplication with React.cache() + +**Impact: MEDIUM (deduplicates within request)** + +Use `React.cache()` for server-side request deduplication. Authentication and database queries benefit most. + +**Usage:** + +```typescript +import { cache } from 'react' + +export const getCurrentUser = cache(async () => { + const session = await auth() + if (!session?.user?.id) return null + return await db.user.findUnique({ + where: { id: session.user.id } + }) +}) +``` + +Within a single request, multiple calls to `getCurrentUser()` execute the query only once. + +**Avoid inline objects as arguments:** + +`React.cache()` uses shallow equality (`Object.is`) to determine cache hits. Inline objects create new references each call, preventing cache hits. + +**Incorrect: always cache miss** + +```typescript +const getUser = cache(async (params: { uid: number }) => { + 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 +``` + +**Correct: cache hit** + +```typescript +const params = { uid: 1 } +getUser(params) // Query runs +getUser(params) // Cache hit (same reference) +``` + +If you must pass objects, pass the same reference: + +**Next.js-Specific Note:** + +In Next.js, the `fetch` API is automatically extended with request memoization. Requests with the same URL and options are automatically deduplicated within a single request, so you don't need `React.cache()` for `fetch` calls. However, `React.cache()` is still essential for other async tasks: + +- Database queries (Prisma, Drizzle, etc.) + +- Heavy computations + +- Authentication checks + +- File system operations + +- Any non-fetch async work + +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 + +**Impact: MEDIUM (faster response times)** + +Use Next.js's `after()` to schedule work that should execute after a response is sent. This prevents logging, analytics, and other side effects from blocking the response. + +**Incorrect: blocks response** + +```tsx +import { logUserAction } from '@/app/utils' + +export async function POST(request: Request) { + // Perform mutation + 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' }), { + status: 200, + 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' + +export async function POST(request: Request) { + // Perform mutation + 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' }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) +} +``` + +The response is sent immediately while logging happens in the background. + +**Common use cases:** + +- Analytics tracking + +- Audit logging + +- Sending notifications + +- Cache invalidation + +- Cleanup tasks + +**Important notes:** + +- `after()` runs even if the response fails or redirects + +- Works in Server Actions, Route Handlers, and Server Components + +Reference: [https://nextjs.org/docs/app/api-reference/functions/after](https://nextjs.org/docs/app/api-reference/functions/after) + +--- + +## 4. Client-Side Data Fetching + +**Impact: MEDIUM-HIGH** + +Automatic deduplication and efficient data fetching patterns reduce redundant network requests. + +### 4.1 Deduplicate Global Event Listeners + +**Impact: LOW (single listener for N components)** + +Use `useSWRSubscription()` to share global event listeners across component instances. + +**Incorrect: N instances = N listeners** + +```tsx +function useKeyboardShortcut(key: string, callback: () => void) { + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.metaKey && e.key === key) { + callback() + } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [key, callback]) +} +``` + +When using the `useKeyboardShortcut` hook multiple times, each instance will register a new listener. + +**Correct: N instances = 1 listener** + +```tsx +import useSWRSubscription from 'swr/subscription' + +// Module-level Map to track callbacks per key +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.get(key)!.add(callback) + + return () => { + const set = keyCallbacks.get(key) + if (set) { + set.delete(callback) + if (set.size === 0) { + keyCallbacks.delete(key) + } + } + } + }, [key, callback]) + + useSWRSubscription('global-keydown', () => { + const handler = (e: KeyboardEvent) => { + if (e.metaKey && keyCallbacks.has(e.key)) { + keyCallbacks.get(e.key)!.forEach(cb => cb()) + } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }) +} + +function Profile() { + // Multiple shortcuts will share the same listener + useKeyboardShortcut('p', () => { /* ... */ }) + useKeyboardShortcut('k', () => { /* ... */ }) + // ... +} +``` + +### 4.2 Use Passive Event Listeners for Scrolling Performance + +**Impact: MEDIUM (eliminates scroll delay caused by event listeners)** + +Add `{ passive: true }` to touch and wheel event listeners to enable immediate scrolling. Browsers normally wait for listeners to finish to check if `preventDefault()` is called, causing scroll delay. + +**Incorrect:** + +```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) + + return () => { + 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 }) + + return () => { + document.removeEventListener('touchstart', handleTouch) + document.removeEventListener('wheel', handleWheel) + } +}, []) +``` + +**Use passive when:** tracking/analytics, logging, any listener that doesn't call `preventDefault()`. + +**Don't use passive when:** implementing custom swipe gestures, custom zoom controls, or any listener that needs `preventDefault()`. + +### 4.3 Use SWR for Automatic Deduplication + +**Impact: MEDIUM-HIGH (automatic deduplication)** + +SWR enables request deduplication, caching, and revalidation across component instances. + +**Incorrect: no deduplication, each instance fetches** + +```tsx +function UserList() { + const [users, setUsers] = useState([]) + useEffect(() => { + fetch('/api/users') + .then(r => r.json()) + .then(setUsers) + }, []) +} +``` + +**Correct: multiple instances share one request** + +```tsx +import useSWR from 'swr' + +function UserList() { + const { data: users } = useSWR('/api/users', fetcher) +} +``` + +**For immutable data:** + +```tsx +import { useImmutableSWR } from '@/lib/swr' + +function StaticContent() { + const { data } = useImmutableSWR('/api/config', fetcher) +} +``` + +**For mutations:** + +```tsx +import { useSWRMutation } from 'swr/mutation' + +function UpdateButton() { + const { trigger } = useSWRMutation('/api/user', updateUser) + return +} +``` + +Reference: [https://swr.vercel.app](https://swr.vercel.app) + +### 4.4 Version and Minimize localStorage Data + +**Impact: MEDIUM (prevents schema conflicts, reduces storage size)** + +Add version prefix to keys and store only needed fields. Prevents schema conflicts and accidental storage of sensitive data. + +**Incorrect:** + +```typescript +// No version, stores everything, no error handling +localStorage.setItem('userConfig', JSON.stringify(fullUserObject)) +const data = localStorage.getItem('userConfig') +``` + +**Correct:** + +```typescript +const VERSION = 'v2' + +function saveConfig(config: { theme: string; language: string }) { + try { + localStorage.setItem(`userConfig:${VERSION}`, JSON.stringify(config)) + } catch { + // Throws in incognito/private browsing, quota exceeded, or disabled + } +} + +function loadConfig() { + try { + const data = localStorage.getItem(`userConfig:${VERSION}`) + return data ? JSON.parse(data) : null + } catch { + return null + } +} + +// Migration from v1 to v2 +function migrate() { + try { + 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') + } + } catch {} +} +``` + +**Store minimal fields from server responses:** + +```typescript +// 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 + })) + } catch {} +} +``` + +**Always wrap in try-catch:** `getItem()` and `setItem()` throw in incognito/private browsing (Safari, Firefox), when quota exceeded, or when disabled. + +**Benefits:** Schema evolution via versioning, reduced storage size, prevents storing tokens/PII/internal flags. + +--- + +## 5. Re-render Optimization + +**Impact: MEDIUM** + +Reducing unnecessary re-renders minimizes wasted computation and improves UI responsiveness. + +### 5.1 Calculate Derived State During Rendering + +**Impact: MEDIUM (avoids redundant renders and state drift)** + +If a value can be computed from current props/state, do not store it in state or update it in an effect. Derive it during render to avoid extra renders and state drift. Do not set state in effects solely in response to prop changes; prefer derived values or keyed resets instead. + +**Incorrect: redundant state and effect** + +```tsx +function Form() { + const [firstName, setFirstName] = useState('First') + const [lastName, setLastName] = useState('Last') + const [fullName, setFullName] = useState('') + + useEffect(() => { + setFullName(firstName + ' ' + lastName) + }, [firstName, lastName]) + + return

{fullName}

+} +``` + +**Correct: derive during render** + +```tsx +function Form() { + const [firstName, setFirstName] = useState('First') + const [lastName, setLastName] = useState('Last') + const fullName = firstName + ' ' + lastName + + return

{fullName}

+} +``` + +Reference: [https://react.dev/learn/you-might-not-need-an-effect](https://react.dev/learn/you-might-not-need-an-effect) + +### 5.2 Defer State Reads to Usage Point + +**Impact: MEDIUM (avoids unnecessary subscriptions)** + +Don't subscribe to dynamic state (searchParams, localStorage) if you only read it inside callbacks. + +**Incorrect: subscribes to all searchParams changes** + +```tsx +function ShareButton({ chatId }: { chatId: string }) { + const searchParams = useSearchParams() + + const handleShare = () => { + const ref = searchParams.get('ref') + shareChat(chatId, { ref }) + } + + return +} +``` + +**Correct: reads on demand, no subscription** + +```tsx +function ShareButton({ chatId }: { chatId: string }) { + const handleShare = () => { + const params = new URLSearchParams(window.location.search) + const ref = params.get('ref') + shareChat(chatId, { ref }) + } + + return +} +``` + +### 5.3 Do not wrap a simple expression with a primitive result type in useMemo + +**Impact: LOW-MEDIUM (wasted computation on every render)** + +When an expression is simple (few logical or arithmetical operators) and has a primitive result type (boolean, number, string), do not wrap it in `useMemo`. + +Calling `useMemo` and comparing hook dependencies may consume more resources than the expression itself. + +**Incorrect:** + +```tsx +function Header({ user, notifications }: Props) { + const isLoading = useMemo(() => { + return user.isLoading || notifications.isLoading + }, [user.isLoading, notifications.isLoading]) + + if (isLoading) return + // return some markup +} +``` + +**Correct:** + +```tsx +function Header({ user, notifications }: Props) { + const isLoading = user.isLoading || notifications.isLoading + + if (isLoading) return + // return some markup +} +``` + +### 5.4 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.5 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]) + + if (loading) return + return
{avatar}
+} +``` + +**Correct: skips computation when loading** + +```tsx +const UserAvatar = memo(function UserAvatar({ user }: { user: User }) { + const id = useMemo(() => computeAvatarId(user), [user]) + return +}) + +function Profile({ user, loading }: Props) { + 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 + +**Impact: LOW (minimizes effect re-runs)** + +Specify primitive dependencies instead of objects to minimize effect re-runs. + +**Incorrect: re-runs on any user field change** + +```tsx +useEffect(() => { + console.log(user.id) +}, [user]) +``` + +**Correct: re-runs only when id changes** + +```tsx +useEffect(() => { + console.log(user.id) +}, [user.id]) +``` + +**For derived state, compute outside effect:** + +```tsx +// Incorrect: runs on width=767, 766, 765... +useEffect(() => { + if (width < 768) { + enableMobileMode() + } +}, [width]) + +// Correct: runs only on boolean transition +const isMobile = width < 768 +useEffect(() => { + if (isMobile) { + enableMobileMode() + } +}, [isMobile]) +``` + +### 5.7 Put Interaction Logic in Event Handlers + +**Impact: MEDIUM (avoids effect re-runs and duplicate side effects)** + +If a side effect is triggered by a specific user action (submit, click, drag), run it in that event handler. Do not model the action as state + effect; it makes effects re-run on unrelated changes and can duplicate the action. + +**Incorrect: event modeled as state + effect** + +```tsx +function Form() { + const [submitted, setSubmitted] = useState(false) + const theme = useContext(ThemeContext) + + useEffect(() => { + if (submitted) { + post('/api/register') + showToast('Registered', theme) + } + }, [submitted, theme]) + + return +} +``` + +**Correct: do it in the handler** + +```tsx +function Form() { + const theme = useContext(ThemeContext) + + function handleSubmit() { + post('/api/register') + showToast('Registered', theme) + } + + 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 + +**Impact: MEDIUM (reduces re-render frequency)** + +Subscribe to derived boolean state instead of continuous values to reduce re-render frequency. + +**Incorrect: re-renders on every pixel change** + +```tsx +function Sidebar() { + const width = useWindowWidth() // updates continuously + const isMobile = width < 768 + return