Purpose
You enforce hook development standards for Next.js projects using SWR. Every custom hook in src/hooks/ MUST follow these rules. Apply them when creating new hooks, modifying existing ones, or reviewing hook code. Target SWR 2.x APIs (useSWR, useSWRMutation, useSWRInfinite).
Scope: server state only — data fetching, caching, revalidation, and mutations via SWR. This skill does NOT cover client state management (Zustand, Redux, Recoil, Jotai).
This skill is specifically for Next.js App Router projects where:
- Server Components handle initial data fetching
- Client Components use SWR hooks for client-side caching, revalidation, and mutations
- The
'use client'directive separates server and client boundaries - The
src/directory and@/import alias are standard conventions
When to Run
- Creating a new hook in
src/hooks/ - Modifying an existing hook
- Reviewing hook code for quality
- Migrating legacy hooks to the correct pattern
- Adding SSR fallback data to pages using SWR
- Setting up SWR configuration or middleware
File Structure
Every hook MUST live in its own directory with this exact layout:
src/hooks/
└── use-hook-name/
├── index.ts # Barrel export (named, never export *)
├── use-hook-name.ts # Implementation
├── use-hook-name.types.ts # Types (when needed, never inline complex types)
└── use-hook-name.test.ts # Co-located test
Rules
- Directory name = kebab-case, matches the hook name:
use-favorite-toggle/. Why: consistent naming enables predictable imports and automated tooling. - No standalone files — every hook gets a directory, even simple ones. Why: uniform structure makes it trivial to add types, tests, or co-located files later without restructuring.
- No
export *in barrel — use explicit named exports. Why: explicit exports enable tree-shaking and make the public API visible at a glance.
// index.ts — CORRECT
export { useFavoriteToggle } from './use-favorite-toggle';
export type {
ToggleFavoriteRequest,
ToggleFavoriteResponse,
UseFavoriteToggleReturn,
} from './use-favorite-toggle.types';
// index.ts — WRONG
export * from './use-favorite-toggle';
- Types file — create
use-hook-name.types.tswhen you have interfaces for params, responses, or return types. Keep types inline only for trivial cases (1-2 simple types). 'use client'directive — REQUIRED as the first line of every hook file that uses SWR or any React hooks (useState,useEffect,useReducer,useRef,useSWR,useSWRMutation,useActionState, etc.). Server Components cannot use hooks — the directive marks the client boundary.
Data Fetching — SWR (MANDATORY)
NEVER use manual fetch() + useState + useEffect for data fetching. Always use SWR. The core pattern is useSWR(key, fetcher, options). Use useSWRMutation for mutations (POST, PUT, DELETE) and useSWRInfinite for paginated or infinite-scroll data. Define reusable fetcher functions in @/lib/fetchers/ and pass them to SWR hooks or configure a default fetcher via SWRConfig.
For full templates (query, mutation, infinite loading, optimistic updates), loading state guide, conditional fetching, polling, and revalidation patterns, see references/swr-patterns.md.
Key Factory — swrKeys (MANDATORY)
All SWR keys MUST come from the factory in @/lib/swr-keys.ts. NEVER hardcode key arrays. Why: centralized keys prevent cache collisions and make invalidation patterns discoverable.
// CORRECT
useSWR(swrKeys.favorites.status(itemId), fetcher)
// WRONG — hardcoded key
useSWR(['favorites', 'status', itemId], fetcher)
Factory Example
// @/lib/swr-keys.ts
export const swrKeys = {
favorites: {
all: ['favorites'] as const,
status: (itemId: string) => ['favorites', 'status', itemId] as const,
},
user: {
list: () => ['user', 'list'] as const,
profile: (userId: string) => ['user', 'profile', userId] as const,
},
products: {
all: ['products'] as const,
detail: (productId: string) => ['products', 'detail', productId] as const,
},
} as const;
Conditional Fetching
Pass null as the key to disable fetching. SWR will not fire the request until the key is non-null:
useSWR(userId ? swrKeys.user.profile(userId) : null, fetcher)
Cache Invalidation Strategies
| Action | Code |
|---|---|
| Revalidate one key | mutate(swrKeys.user.profile(id)) |
| Revalidate with new data | mutate(key, newData, { revalidate: false }) |
| Revalidate matching keys | mutate((key) => Array.isArray(key) && key[0] === 'favorites') |
| Bound mutate (from hook) | const { mutate } = useSWR(key, fetcher) |
WARNING: Never call mutate inside useEffect based on data changes — this creates revalidation loops. Use SWR's onSuccess callback or event handlers instead.
Global Configuration — SWRConfig
Centralize default SWR options in @/lib/swr-config.ts. Never scatter configuration across individual hooks.
// @/lib/swr-config.ts
export const SWR_CONFIG = {
dedupingInterval: 2000,
revalidateOnFocus: true,
revalidateOnReconnect: true,
errorRetryCount: 3,
} as const;
Wrap your app layout with SWRConfig to apply defaults and a global fetcher:
// @/app/providers.tsx
'use client';
import { SWRConfig } from 'swr';
import { SWR_CONFIG } from '@/lib/swr-config';
import { defaultFetcher } from '@/lib/fetchers/default-fetcher';
export function Providers({ children }: { children: React.ReactNode }) {
return (
<SWRConfig value={{ ...SWR_CONFIG, fetcher: defaultFetcher }}>
{children}
</SWRConfig>
);
}
SSR Fallback Pattern
This is the key Next.js integration pattern. SWR's fallback option allows Server Components to pre-fetch data and pass it to client components, eliminating loading states on initial render.
How It Works
- Server Component fetches data at request time (or via
generateStaticParams) - Server Component passes data as
fallbackto a nestedSWRConfig - Client Components using
useSWRwith matching keys receive instant data — no loading spinner
Server Component (fetches and provides fallback)
// app/users/page.tsx (Server Component — no 'use client')
import { SWRConfig, unstable_serialize } from 'swr';
import { swrKeys } from '@/lib/swr-keys';
import { UserList } from './user-list';
async function fetchUsers() {
const res = await fetch('https://api.example.com/users', { next: { revalidate: 60 } });
return res.json();
}
export default async function UsersPage() {
const users = await fetchUsers();
return (
<SWRConfig value={{ fallback: { [unstable_serialize(swrKeys.user.list())]: users } }}>
<UserList />
</SWRConfig>
);
}
Client Component (consumes fallback via useSWR)
// app/users/user-list.tsx (Client Component)
'use client';
import useSWR from 'swr';
import { swrKeys } from '@/lib/swr-keys';
export function UserList() {
const { data: users } = useSWR(swrKeys.user.list());
// `users` is immediately available from fallback — no loading state on first render
// SWR still revalidates in the background to keep data fresh
return <ul>{users?.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
}
Use unstable_serialize from swr to convert array keys to the string format SWR uses internally. This ensures the fallback key matches the key used by useSWR in client components.
For the full SSR fallback pattern with error handling, nested fallbacks, and dynamic routes, see references/swr-patterns.md.
React 19 & React Compiler
Next.js has React Compiler support. If enabled, do NOT write manual useMemo or useCallback in new hooks — the compiler handles memoization at build time. Why: manual memos add noise and can conflict with compiler optimizations.
Use React 19 hooks where appropriate:
useActionState+useFormStatusfor Server Action formsuseOptimisticfor optimistic UI updates (complements SWR's optimistic mutation pattern)use()for reading promises and context in render
For useActionState and useOptimistic templates, use() rules, and compiler edge cases, see references/react19-and-compiler.md.
Hook Composition
Hooks can (and should) compose other hooks from @/hooks/:
export function useEditProfileForm(): UseEditProfileFormReturn {
const { user } = useAuth();
const { data: profile, isLoading: isProfileLoading } = useSWR(
user?.id ? swrKeys.user.profile(user.id) : null,
fetcher,
);
const { trigger: updateProfile, isMutating } = useSWRMutation(
swrKeys.user.profile(user?.id ?? ''),
updateProfileFetcher,
);
// Derive state, combine loading flags, etc.
}
Rules:
- Call hooks in consistent order (React rules of hooks)
- Pass derived values between hooks
- Keep composed hooks under 300 lines; if too large, split the composition
- Each sub-hook should handle one concern (auth, data, mutation)
State Machines with useReducer
For multi-step flows (transfers, wizards), use a typed reducer as a state machine. Define discriminated union types for state and actions, then a pure flowReducer function with switch on action.type. Typed transitions prevent invalid state combinations. The reducer is a pure function — test it directly without renderHook.
type FlowState =
| { step: 'idle' }
| { step: 'confirm'; recipientId: string }
| { step: 'submitting'; recipientId: string }
| { step: 'success'; txHash: string }
| { step: 'error'; error: Error };
type FlowAction =
| { type: 'START'; recipientId: string }
| { type: 'SUBMIT' }
| { type: 'SUCCESS'; txHash: string }
| { type: 'FAIL'; error: Error }
| { type: 'RESET' };
function flowReducer(state: FlowState, action: FlowAction): FlowState { /* switch on action.type */ }
Types Conventions
// use-hook-name.types.ts
export interface UseHookNameParams {
id: string;
enabled?: boolean;
}
export interface HookNameResponse {
success: boolean;
data: SomeData;
error?: string;
}
export interface UseHookNameReturn {
data: SomeData | null;
isLoading: boolean;
error: string | null;
}
Discriminated Unions for Complex Return Types
When a hook has distinct states, use discriminated unions instead of nullable fields. TypeScript narrows correctly at call site:
type UseItemResult =
| { status: 'loading' }
| { status: 'success'; data: Item }
| { status: 'error'; error: Error };
Tuple Returns with as const
For hooks returning [value, setter] pairs, use as const to preserve tuple types instead of (T | Function)[]:
return [value, toggle] as const; // infers [boolean, () => void]
Rules
- No
any— strict TypeScript, always - Explicit return type on the hook function signature
- Separate types file for hooks with 3+ interfaces
- Import with
typekeyword:import type { ... } from './use-hook-name.types' - Don't define unused types — if an interface/type isn't imported anywhere, delete it
Hook Size & Responsibility
| Guideline | Limit |
|---|---|
| Max lines per hook | ~300 lines |
| Single responsibility | One concern per hook |
Max useEffect per hook |
2 (prefer 0-1) |
If a hook exceeds 300 lines or handles multiple concerns, split it:
- Data fetching -> separate SWR hook
- Filter/form state -> separate state hook
- UI behavior -> separate UI hook
- Compose them in a parent hook or component if needed
Testing
Use renderHook from @testing-library/react wrapped with SWRConfig configured with { dedupingInterval: 0, provider: () => new Map() } to isolate cache between tests. Test reducers as pure functions without renderHook.
For full testing templates and patterns, see references/testing-hooks.md.
Shared Utilities — Don't Duplicate
Before writing mapping functions, type converters, or helpers inside a hook:
- Check
@/lib/utils/for existing utilities - Check other hooks for the same function (e.g.,
itemToGridItemexists in 6 hooks — it should be in@/lib/utils/) - If the function is used by 2+ hooks, extract it to
@/lib/utils/ - Shared interfaces used across hooks go in
@/types/, not duplicated in each hook's types file
Anti-Patterns & Verification
Before finishing any hook, check for common anti-patterns (setState during render, useEffect for fetching, manual memoization, hardcoded SWR keys, string-based error matching) and run through the verification checklist covering structure, directives, types, and build.
For the full anti-patterns table and verification checklist, see references/anti-patterns-checklist.md.
Examples
Example 1: "Create a hook for fetching user profile"
Claude creates:
src/hooks/use-user-profile/directory withindex.ts,use-user-profile.ts, anduse-user-profile.types.ts- Hook file starts with
'use client'directive - Uses
useSWR(swrKeys.user.profile(userId), fetcher)withdedupingIntervalfrom@/lib/swr-config - Adds query key
swrKeys.user.profile(userId)to@/lib/swr-keys.ts - Conditional fetching with
userId ? swrKeys.user.profile(userId) : nullwhen userId may be undefined - Types file defines
UseUserProfileParams,UserProfileResponse, andUseUserProfileReturn
Example 2: "Add SSR fallback for product page"
Claude creates:
- Server Component at
app/products/[id]/page.tsxthat fetches product data - Uses
SWRConfigwithfallback: { [unstable_serialize(swrKeys.products.detail(id))]: product }to pass data to client - Client Component at
app/products/[id]/product-detail.tsxwith'use client'directive - Client component uses
useSWR(swrKeys.products.detail(id))— data is instantly available from fallback, no loading state - SWR revalidates in the background to keep data fresh after initial render
Example 3: "This hook has useState + useEffect for fetching, fix it"
Claude:
- Identifies the
useState+useEffect+fetch()anti-pattern - Refactors to use
useSWRwith a centralized fetcher from@/lib/fetchers/ - Adds
'use client'directive if missing - Removes manual loading/error state management (replaced by SWR's
isLoading,error) - Adds key to
swrKeysfactory in@/lib/swr-keys.ts(never hardcoded) - Verifies against the anti-patterns checklist in
references/anti-patterns-checklist.md