GraphQL for Frontend Developers: Queries, Mutations, and Caching
A practical guide to using GraphQL in frontend applications — writing queries and mutations, client-side caching with Apollo and urql, and patterns for React integration.
A practical guide to caching in Next.js 15+ — Request Memoization, Data Cache, Full Route Cache, and Router Cache demystified with patterns for static generation, ISR, and on-demand revalidation.
By codefunc
Caching is the most confusing part of Next.js. The framework has four distinct cache layers that interact in non-obvious ways. Defaults changed between versions. Documentation mixes legacy APIs with modern ones. If you have ever deployed a page that stubbornly shows stale data, or added cache: 'no-store' everywhere out of frustration, this guide will clarify how caching actually works.
Bottom line: Next.js 15+ has four cache layers: Request Memoization (per-render), Data Cache (persistent fetch), Full Route Cache (pre-rendered HTML), and Router Cache (client navigation). Since Next.js 15, fetch is NOT cached by default — you must opt in. Use revalidate for time-based freshness, revalidateTag for on-demand invalidation after mutations.
| Layer | Scope | Duration | Purpose |
|---|---|---|---|
| Request Memoization | Single render | Request lifetime | Dedupe identical fetches |
| Data Cache | Server | Persistent | Cache fetch results |
| Full Route Cache | Server | Persistent | Cache rendered HTML/RSC |
| Router Cache | Client | Session | Cache visited routes |
Understanding when each layer is used — and when it is bypassed — is essential for predictable behavior.
When you call the same fetch in multiple components during a single render, Next.js deduplicates them automatically.
// layout.tsx
async function Layout({ children }) {
const user = await fetchUser(); // fetch #1
return <div>{children}</div>;
}
// page.tsx
async function Page() {
const user = await fetchUser(); // same URL — memoized, not re-fetched
return <Profile user={user} />;
}
fetchThe Data Cache stores fetch results persistently on the server. This is where ISR (Incremental Static Regeneration) lives.
// NOT cached by default in Next.js 15+
const data = await fetch('https://api.example.com/posts');
// Cache indefinitely (until redeployed)
const data = await fetch('https://api.example.com/posts', {
cache: 'force-cache',
});
// Cache with time-based revalidation (ISR)
const data = await fetch('https://api.example.com/posts', {
next: { revalidate: 3600 }, // revalidate after 1 hour
});
// Never cache
const data = await fetch('https://api.example.com/user', {
cache: 'no-store',
});
| Strategy | When to use |
|---|---|
revalidate: 60 |
Content changes periodically (blog posts) |
revalidate: 0 |
Always fresh (user-specific data) |
cache: 'force-cache' |
Static content that rarely changes |
cache: 'no-store' |
Real-time data, authenticated requests |
// Fetch with tag
const posts = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] },
});
// Invalidate by tag (in Server Action)
import { revalidateTag } from 'next/cache';
async function createPost(data) {
await db.posts.create(data);
revalidateTag('posts'); // bust the cache
}
Next.js pre-renders routes at build time (static) or on first request (dynamic). The rendered HTML and React Server Component payload are cached.
// Fully static — cached at build time
export default function AboutPage() {
return <div>About us</div>;
}
Routes become dynamic when they use:
cookies(), headers(), searchParamsfetch with cache: 'no-store'export const dynamic = 'force-dynamic'// Dynamic — rendered on every request
import { cookies } from 'next/headers';
export default async function DashboardPage() {
const session = cookies().get('session');
const user = await fetchUser(session);
return <Dashboard user={user} />;
}
// Force static generation
export const dynamic = 'force-static';
// Force dynamic rendering
export const dynamic = 'force-dynamic';
// Set revalidation for entire route
export const revalidate = 3600; // 1 hour
The Router Cache stores visited routes in the browser during a session. This enables instant back/forward navigation.
window.location) bypasses the cacheimport { useRouter } from 'next/navigation';
function Component() {
const router = useRouter();
async function handleUpdate() {
await updateData();
router.refresh(); // Invalidate Router Cache for current route
}
}
// app/page.tsx
export const revalidate = 86400; // Revalidate daily
export default async function HomePage() {
const content = await fetch('https://cms.example.com/home', {
next: { tags: ['home'] },
});
return <HomeContent data={content} />;
}
// app/blog/[slug]/page.tsx
export const revalidate = 3600; // Revalidate hourly
export async function generateStaticParams() {
const posts = await fetchAllPosts();
return posts.map((post) => ({ slug: post.slug }));
}
export default async function BlogPost({ params }) {
const post = await fetch(`https://api.example.com/posts/${params.slug}`, {
next: { tags: [`post-${params.slug}`] },
});
return <Article post={post} />;
}
// app/dashboard/page.tsx
export const dynamic = 'force-dynamic';
export default async function DashboardPage() {
const stats = await fetch('https://api.example.com/stats', {
cache: 'no-store',
});
return <Dashboard stats={stats} />;
}
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache';
export async function POST(request: Request) {
const { tag, secret } = await request.json();
if (secret !== process.env.REVALIDATION_SECRET) {
return Response.json({ error: 'Invalid secret' }, { status: 401 });
}
revalidateTag(tag);
return Response.json({ revalidated: true });
}
Webhook from CMS calls this endpoint when content changes.
// Runs on server, can fetch directly
export default async function ProductList() {
const products = await fetch('https://api.example.com/products');
return <ul>{products.map(p => <li key={p.id}>{p.name}</li>)}</ul>;
}
'use client';
// Runs in browser, needs useEffect or library
import { useState, useEffect } from 'react';
export default function SearchResults({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(setResults);
}, [query]);
return <ul>{results.map(r => <li key={r.id}>{r.name}</li>)}</ul>;
}
| Use Server Components for | Use Client Components for |
|---|---|
| Data fetching | Interactive UI (forms, buttons) |
| Accessing backend resources | Browser APIs (localStorage) |
| Keeping secrets server-side | Event handlers (onClick) |
| Large dependencies | useState, useEffect |
| SEO-critical content | Real-time updates |
use cache directive (Next.js 15+)Beyond fetch, you can cache any async function:
async function getUser(id: string) {
'use cache';
// This result is cached
return await db.users.findUnique({ where: { id } });
}
import { cacheTag, cacheLife } from 'next/cache';
async function getPosts() {
'use cache';
cacheTag('posts');
cacheLife('hours'); // Preset: stale 5m, revalidate 1h, expire 1d
return await db.posts.findMany();
}
| Profile | Stale | Revalidate | Expire |
|---|---|---|---|
seconds |
0 | 1s | 60s |
minutes |
5m | 1m | 1h |
hours |
5m | 1h | 1d |
days |
5m | 1d | 1w |
weeks |
5m | 1w | 30d |
max |
5m | 30d | ~indefinite |
// Add to fetch to see caching behavior
const data = await fetch(url, {
next: { revalidate: 60, tags: ['debug'] },
});
console.log('Cache status:', response.headers.get('x-nextjs-cache'));
| Symptom | Likely cause | Fix |
|---|---|---|
| Stale data after deploy | Router Cache | router.refresh() or hard reload |
| Data never updates | cache: 'force-cache' without revalidation |
Add revalidate or tags |
| Too many API calls | Missing memoization | Ensure same URL/options |
| Dynamic when should be static | Using cookies()/headers() |
Move to client or pass as props |
| Build fails with dynamic | generateStaticParams missing |
Add or use dynamicParams |
When debugging API responses, format them with the JSON Formatter to inspect structure. Validate syntax with the JSON Validator before blaming the cache.
Configure caching at the route level:
// app/dashboard/layout.tsx
// Force all child routes to be dynamic
export const dynamic = 'force-dynamic';
// Or set revalidation for all child routes
export const revalidate = 60;
// Opt into Partial Prerendering
export const experimental_ppr = true;
| Option | Values | Effect |
|---|---|---|
dynamic |
'auto', 'force-dynamic', 'force-static', 'error' |
Control rendering mode |
revalidate |
false, 0, number |
Time-based revalidation |
fetchCache |
'auto', 'force-cache', 'force-no-store', etc. |
Default fetch behavior |
runtime |
'nodejs', 'edge' |
Execution environment |
| Pages Router | App Router |
|---|---|
getStaticProps |
Server Component + fetch with cache |
getServerSideProps |
Server Component + dynamic = 'force-dynamic' |
getStaticPaths |
generateStaticParams |
revalidate in getStaticProps |
revalidate route config or fetch option |
unstable_revalidate |
revalidatePath / revalidateTag |
Next.js caching is powerful but has a learning curve. Start with the defaults — uncached fetch in Next.js 15+. Add caching explicitly where it helps: revalidate for time-based freshness, tags for on-demand invalidation. Use Server Components for data fetching, Client Components for interactivity. When data is stale, check all four cache layers. When debugging, log cache headers and use router.refresh() to clear client cache.
A practical guide to using GraphQL in frontend applications — writing queries and mutations, client-side caching with Apollo and urql, and patterns for React integration.
A practical guide to internationalizing React applications — setting up translations, handling plurals, formatting dates and numbers, and implementing language switching.
Shrink SVG icons for the web — strip comments and whitespace, choose inline vs data URLs, and know when you still need a full SVGO pipeline.
Find a developer tool