Skip to Content
React in a Week
Day 7 · Production with Next.js

Data and caching

Learn how Next.js splits a page into a static shell and streamed parts, how to cache data with 'use cache', and how to turn on the React Compiler.

A page feels fast when the server has already done most of the work before anyone asks for it. A page is correct when it shows the latest data. Those two goals pull in opposite directions, and caching is how you choose between them: you decide which results can be saved and reused, and for how long.

This lesson gives you an introduction, not the whole story. Caching is one of the deepest topics in Next.js, and the default settings here are designed so that you can ignore most of it until you need it.

The static shell and the streamed parts

New apps created with create-next-app have Cache Components turned on. You can see it in next.config.ts:

next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
};

export default nextConfig;

With Cache Components, Next.js renders each route ahead of time, during next build. Everything that can be finished without a visitor goes into a static shell: plain HTML that can be sent instantly, often from a server close to the visitor. Anything that has to wait for a real request leaves a hole in the shell, with a Suspense fallback in it. When someone visits, the server fills each hole and streams the result in.

For the Reading List home page, that means:

  • In the static shell: the layout, the My Reading List heading, the add-book form, the Your books heading, and the text "Loading your books...".
  • Streamed per request: the BookList component, because getBooks() reads data at request time.

Here is what a visitor experiences, acted out with use() and Suspense from Day 6. Click the button to visit the page again.

Live code· A static shell with a streamed hole

Why lib/books.ts calls connection()

Next.js sorts work by what it waits on. A database query or a fetch waits for something outside your code, so Next.js treats it as request-time data: it must be inside a Suspense boundary or cached. Reading an array from memory waits on nothing. Next.js would finish it during the build and bake that copy of the list into the static shell forever.

The Reading List's pretend database is an in-memory array, so getBooks calls connection() first:

lib/books.ts
import { connection } from 'next/server';

export async function getBooks(): Promise<Book[]> {
  await connection(); // read at request time, not at build time
  return books;
}

connection() from next/server means "stop here until there is a real request". Everything after it runs for each visitor, so a newly added book shows up. In other words, data is dynamic by default: unless you ask for caching, it is read fresh on every request.

Quick check

With Cache Components on, which part of the Reading List home page is NOT in the static shell?

Caching data with 'use cache'

Reading the list on every request is correct, but each visit costs a database query. If many people view the same data, you can save the result and reuse it. Put the 'use cache' directive at the top of an async function, and Next.js stores its return value:

lib/books.ts
import { cacheLife, cacheTag } from 'next/cache';

export async function getBooks(): Promise<Book[]> {
  'use cache';
  cacheLife('hours');
  cacheTag('books');
  return books;
}

The connection() call is gone: a cached function runs once and its result is reused, so it can't wait for a request. Two helpers describe the cache entry:

  • cacheLife('hours') sets how long the result stays fresh. With the built-in hours profile, Next.js refreshes it in the background after an hour, and throws it away if nobody asks for it within a day. Other profiles include minutes, days and max.
  • cacheTag('books') gives the entry a label, so you can throw away every entry with that label after a change.

Because the cached result doesn't depend on a request, the book list can now go into the static shell too. The arguments of a cached function are part of its cache key, so getBook('1') and getBook('2') would be saved as separate entries.

Throwing away stale data

A cache is only useful if you can clear it when the data changes. After a Server Function changes books, you have three tools:

FunctionWhat it clearsUse it when
revalidatePath('/')Everything cached for one routeYou know which page shows the data
updateTag('books')Every entry tagged books, right awayA user changed something and should see it immediately. Server Functions only
revalidateTag('books', 'max')Every entry tagged books, refreshed in the backgroundOthers can see the old version for a moment, for example after a CMS update

The Next.js docs prefer tags when you can use them, because one tag reaches every page that uses the data. For the Reading List you can use both:

app/actions.ts
import { revalidatePath, updateTag } from 'next/cache';

export async function addBook(prevState: FormState, formData: FormData): Promise<FormState> {
  // ...validate and save, as before
  revalidatePath('/'); // refresh the page the user is on
  updateTag('books'); // and every other cached entry tagged 'books'
  return { error: null, message: `Added “${book.title}” to your list.` };
}

Here is a toy model of a tagged cache. It is not how Next.js stores entries, but it behaves the same way: a hit returns the saved result, and clearing a tag forces the next visit to run the query again.

Live code· A toy tagged cache

Visit twice and the second visit is an instant hit. Add a book without invalidation and visit again: you still see the old count. That is a stale cache. Add one with updateTag and the next visit runs the query and shows the new count.

The 'use cache' and cacheLife APIs need cacheComponents: true. Older tutorials, and projects created without Cache Components, use a different model with options like export const revalidate = 60 or export const dynamic = 'force-dynamic'. With Cache Components on, those exports cause an error. Check which model a tutorial uses before you copy its code.

Quick check

A user adds a book and should see it in the list right away, on every page that shows books. Which call fits best inside the Server Function?

The React Compiler in Next.js

On Day 6 you turned on the React Compiler, which memoizes components and values for you, so you rarely need useMemo, useCallback or memo. That is a different kind of caching: it saves work while React re-renders in the browser, while 'use cache' saves data and output on the server.

The recommended create-next-app defaults leave the compiler off. Turning it on takes two steps.

Install the Babel plugin

npm install -D babel-plugin-react-compiler

Switch it on in next.config.ts

next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
  reactCompiler: true,
};

export default nextConfig;

Restart npm run dev after changing the config.

Next.js only runs the compiler on files that need it, such as files with JSX or hooks, to keep builds fast. Your code doesn't change. Keep writing plain components, follow the Rules of React, and let the compiler handle the memoizing.

Try it

Cache the detail page's data

The detail page calls getBook(id), which currently uses connection(). Change it to a cached function tagged books with the hours lifetime. Then make sure that adding, changing the status of, or deleting a book clears it. What happens to the id argument in the cache?

Show solution
lib/books.ts
import { cacheLife, cacheTag } from 'next/cache';

export async function getBook(id: string): Promise<Book | undefined> {
  'use cache';
  cacheLife('hours');
  cacheTag('books');
  return books.find((book) => book.id === id);
}
app/actions.ts
import { revalidatePath, updateTag } from 'next/cache';

export async function cycleStatus(id: string) {
  await cycleBookStatus(id);
  revalidatePath('/');
  updateTag('books');
}

export async function deleteBook(id: string) {
  await removeBook(id);
  revalidatePath('/');
  updateTag('books');
}

Add the same updateTag('books') line to addBook. The id argument becomes part of the cache key, so each book gets its own entry, and one updateTag('books') clears all of them because they share the tag.

The detail page still awaits params, which is request-time data, so keep the loading.tsx file next to it.

Day 7 progress

0/8
Next: Accessibility

On this page