Most data in a Next.js SaaS lives in server components -- fetched at request time, no client state. But the moment you add a feature that updates without a full navigation (editing a post, toggling a setting, deleting a row), you need client-side data fetching. SWR is the right tool for this.
This post covers the specific pattern used in this boilerplate: useSWR for reads, useSWRMutation for writes, and the mutate() call that keeps the cache in sync.
All SWR hooks share four utility functions from lib/fetcher.ts:
export const fetcher = (url: string) =>
fetch(url, { headers: authHeaders() }).then(res => {
if (!res.ok) throw new Error(res.statusText);
return res.json();
});
export const poster = <T>(url: string, { arg }: { arg: T }) =>
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(arg),
}).then(res => {
if (!res.ok) throw new Error(res.statusText);
return res.json();
});
// putter and deleter follow the same shape
authHeaders() reads the JWT from localStorage and attaches the Authorization: Bearer ... header. You define it once in lib/fetcher.ts and never repeat it in individual hooks.
Put all hooks in hooks/api/*.ts, one file per resource.
// hooks/api/usePosts.ts
import useSWR from 'swr';
import { fetcher } from '@/lib/fetcher';
import type { Post } from '@/modules/post/post.types';
export function usePosts() {
const { data, error, isLoading, mutate } = useSWR<Post[]>('/api/posts', fetcher);
return { posts: data ?? [], error, isLoading, mutate };
}
Three things to note:
[] -- never return undefined for list data. Components can safely map over posts without a null check.mutate from the hook -- callers need it to trigger a refresh after a write.'/api/posts') is both the cache key and the URL. SWR deduplicates requests across components that share the same key.// hooks/api/useCreatePost.ts
import useSWRMutation from 'swr/mutation';
import { poster } from '@/lib/fetcher';
import type { CreatePostDto } from '@/modules/post/post.types';
export function useCreatePost() {
const { trigger, isMutating, error } = useSWRMutation(
'/api/posts',
poster<CreatePostDto>
);
return { createPost: trigger, isCreating: isMutating, error };
}
trigger(data) fires the POST. It does not cache the response -- that is the read hook's job.
After a mutation succeeds, call mutate() from the corresponding read hook to refetch:
function NewPostForm() {
const { mutate } = usePosts();
const { createPost, isCreating } = useCreatePost();
async function onSubmit(data: CreatePostDto) {
await createPost(data);
await mutate(); // refetch /api/posts
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* form fields */}
<button disabled={isCreating}>
{isCreating ? 'Creating...' : 'Create post'}
</button>
</form>
);
}
This is the full loop: trigger fires the write, mutate() refreshes the read cache, and the UI updates automatically.
Skip the request until you have a required value:
const { data } = useSWR(
userId ? `/api/users/${userId}` : null,
fetcher
);
Passing null as the key tells SWR not to fetch. Useful when the key depends on auth state or a parent component loading first.
Do NOT use poster with FormData -- the Content-Type: application/json header it sets will break multipart parsing. Use fetch directly:
const res = await fetch('/api/upload', {
method: 'POST',
headers: authHeaders(), // no Content-Type -- browser sets the boundary
body: formData,
});
A full CRUD resource ends up as three hook files in hooks/api/:
hooks/api/
usePosts.ts -- useSWR read
useCreatePost.ts -- useSWRMutation write
useDeletePost.ts -- useSWRMutation delete
Each hook has one job. The component imports all three and composes them.
Set up lib/fetcher.ts once, write one hook per operation in hooks/api/, and call mutate() after every write. Every client-side feature in this boilerplate follows that loop. Once you understand it, wiring a new resource takes about 10 minutes -- write the API route, write the hook, drop it into the component.