Load and mutate data in a Next.js app — fetch directly in Server Components, then use TanStack Query for client-side reads and mutations with caching built in.
Why: in Next.js, components are Server Components unless marked 'use client'. They can be async and fetch directly — no useEffect, no loading flags, no exposed API keys, and the data arrives already rendered. Reach for this first; client fetching is the fallback.
// app/users/page.tsx — a Server Component (no 'use client')
type User = { id: number; name: string }
export default async function UsersPage() {
// Fetch right in the component — this runs on the server
const res = await fetch('https://jsonplaceholder.typicode.com/users')
const users: User[] = await res.json()
return (
<ul>
{users.map((u) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
)
}Why: when data must load or refresh in the browser — polling, search-as-you-type, refetching after a click — TanStack Query handles the hard parts for you: it caches results, avoids firing the same request twice, retries failures, and refreshes data that has gone stale. A hand-rolled useEffect fetch does none of that. Note: requires a <QueryClientProvider> once at the app root; SWR is a lighter alternative with the same idea.
$ pnpm add @tanstack/react-query'use client'
import { useQuery } from '@tanstack/react-query'
type Repo = { name: string; stargazers_count: number }
export default function RepoStats() {
// isPending while loading, error if it failed, data when it's ready
const { data, isPending, error } = useQuery({
queryKey: ['repo', 'tanstack/query'], // cache + dedupe by this key
queryFn: async (): Promise<Repo> => {
const res = await fetch('https://api.github.com/repos/tanstack/query')
if (!res.ok) throw new Error('Request failed')
return res.json()
},
})
if (isPending) return <p>Loading…</p>
if (error) return <p>Error: {error.message}</p>
return (
<p>
{data.name} — {data.stargazers_count} stars
</p>
)
}Why: writes (POST, PUT, DELETE) go through useMutation, which tracks the in-flight state for you (isPending) and lets you mark cached reads as outdated after a change — they refetch automatically, so the screen always ends up matching the server.
'use client'
import { useMutation, useQueryClient } from '@tanstack/react-query'
export default function AddComment({ postId }: { postId: number }) {
const queryClient = useQueryClient()
const addComment = useMutation({
// mutationFn does the actual write; .mutate(…) below triggers it
mutationFn: (text: string) =>
fetch('/api/comments', {
method: 'POST',
body: JSON.stringify({ postId, text }),
}),
onSuccess: () => {
// Mark the cached list stale → it refetches automatically
queryClient.invalidateQueries({ queryKey: ['comments', postId] })
},
})
return (
<button
disabled={addComment.isPending}
onClick={() => addComment.mutate('Great post!')}
>
{addComment.isPending ? 'Sending…' : 'Add comment'}
</button>
)
}