Claude Code Boilerplate
FeaturesPricingBlogDocs
Get started →

Product

  • Features
  • Pricing
  • Skills

Compare

  • vs ShipFast
  • vs MakerKit
  • vs supastarter

Resources

  • Docs
  • Blog
  • Discord

Legal

  • License
  • Privacy Policy
  • Terms of Service
Claude Code Boilerplate

© 2026 Claude Code Boilerplate. All rights reserved.

← All posts

Client-side data fetching in Next.js App Router with SWR -- reads, mutations, and cache sync

June 2, 2026
nextjsswrsaasreact

Client-side data fetching in Next.js App Router with SWR

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.

The fetcher utilities

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.

Reading data with useSWR

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:

  • Default the array to [] -- never return undefined for list data. Components can safely map over posts without a null check.
  • Return mutate from the hook -- callers need it to trigger a refresh after a write.
  • The key ('/api/posts') is both the cache key and the URL. SWR deduplicates requests across components that share the same key.

Writing data with useSWRMutation

// 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.

Keeping the cache in sync

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.

Conditional fetching

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.

File uploads are a special case

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,
});

The pattern in one sentence

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.

Takeaway

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.