Master Next.js 16 Caching
Build a Town Bulletin Board to master Next.js 16 caching and PPR.
Introduction
30 Second Summary
You have probably visited a website that took seconds to load every time you refreshed the page. Caching fixes slow pages, but the real challenge is keeping cached content fresh when something changes.
In this project, you will build a Town Bulletin Board app using Next.js 16 to learn how its modern caching system works. You will feel the pain of a slow, uncached page, then fix it step by step with Cache Components and on-demand cache invalidation.
What You'll Build
A Town Bulletin Board app that uses a mock data source with a deliberate 2-second delay, so you can feel the measurable speed difference between cached and uncached page loads.
By the end of this project, you'll have:
- A cached announcement list powered by the use cache directive, turning a 2-second page load into an instant one.
- An admin publish form with on-demand cache invalidation using updateTag, so new announcements appear immediately.
- A page that mixes cached and dynamic content using Suspense and Partial Prerendering.
- Secret Mission: Add custom cacheLife profiles for different content urgency levels.
Are there any prerequisites?
This project assumes basic experience with the Next.js App Router, including pages, layouts, and components. Familiarity with TypeScript and React is also helpful.
Set Up Your Next.js 16 Project
To build the Town Bulletin Board, you need a fresh Next.js 16 project with the right features turned on.
In this step, you'll scaffold the app, enable Cache Components (the system that powers everything you'll build in this project), and set up a clean starting page.
In this step, get ready to:
- Scaffold a new Next.js 16 project with TypeScript and App Router.
- Enable Cache Components in the project configuration.
- Replace the default page with a Town Bulletin Board placeholder.
Check Node.js and scaffold the project
Next.js 16 requires Node.js 20.9 or higher. Let's confirm your version before scaffolding the project.
- Confirm your Node.js version by running this command:
node -v
You should see a version number of v20.9.0 or higher. If you need to upgrade, install the latest LTS from nodejs.org.
- Move to your Desktop by running this command:
cd ~/Desktop
- Scaffold your Next.js project and move into the new folder by running these commands:
npx create-next-app@latest town-bulletin --yes
cd town-bulletin
What does this command do?
npx create-next-app@latest downloads and runs the official Next.js project scaffolder.
The --yes flag accepts all recommended defaults: TypeScript, ESLint, Tailwind CSS, and App Router. This means you won't be asked any prompts during setup.
You may see an install prompt
If this is your first time running the command, npx may ask you to confirm installing the create-next-app package. Type y and press Enter to continue.
- Confirm the project was created by running:
ls
You should see files like package.json, next.config.ts, and an app/ folder in the output.
Scaffold failed or command not found?
- If you see a "command not found" error for npx, your Node.js installation may not be on your system PATH. Reinstall Node.js from nodejs.org and try again.
- If the scaffold hangs or times out, check your internet connection. The command downloads packages from the npm registry.
- Still stuck? Help me troubleshoot my create-next-app scaffold.
- Open the town-bulletin folder in VS Code. Press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows) to open the command palette.
- Type Open Folder and select the option that appears.
- Navigate to your Desktop, select the town-bulletin folder, and click Open.
- Start the development server by running this command in the VS Code terminal:
npm run dev
- Open your browser and go to http://localhost:3000.
You should see the default Next.js welcome page. This confirms the scaffold is working.
Keep the dev server running
Leave this terminal open for the rest of the project. The dev server watches your files and updates the browser automatically when you save changes.
Page not loading at localhost:3000?
- If port 3000 is already in use, the dev server picks the next available port. Check your terminal output for the actual URL.
- Make sure you ran npm run dev from inside the town-bulletin folder, not your Desktop.
- Still stuck? Help me get my Next.js dev server running.
Enable Cache Components
Cache Components is the Next.js 16 feature that makes pages load instantly by caching rendered output. Enabling it unlocks the 'use cache' directive, cacheLife, cacheTag, and Partial Prerendering.
- In the VS Code Explorer sidebar, click next.config.ts to open it.
- Replace the entire contents of the file with this code:
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
// Enable the Cache Components system (use cache, cacheLife, cacheTag, PPR)
cacheComponents: true,
}
export default nextConfig
What does cacheComponents do?
This single flag turns on the entire Next.js 16 caching system. It replaces several older experimental flags that were used in earlier versions.
With it enabled, you can use 'use cache' to cache component output, cacheLife to control how long caches last, and cacheTag to label caches for on-demand invalidation. You'll use all three in upcoming steps.
- Save next.config.ts.
- Check your terminal. The dev server restarts automatically. Confirm there are no error messages in the output.
Seeing a config error in the terminal?
- Make sure the property name is cacheComponents (camelCase, no hyphens). A typo here causes a silent config error.
- Check that the file still uses the .ts extension, not .js. The scaffold created a TypeScript config file.
- Still stuck? Help me fix my next.config.ts error.
✔️ Awesome, I've got everything!
Great. Double check you've saved next.config.ts before moving on.
ⓧ I'd like to double check the full code
Here is the full next.config.ts file. Make sure yours matches exactly.
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
}
export default nextConfig
Clean up the scaffold
The default Next.js page has a lot of boilerplate content you don't need. Let's replace it with a clean placeholder for the Town Bulletin Board.
- In the Explorer sidebar, expand the app/ folder and click page.tsx to open it.
- Delete everything in the file and replace it with this code:
export default function Home() {
return (
// Center content with max width and padding
<main className="max-w-2xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-4">Town Bulletin Board</h1>
<p className="text-gray-600">Official announcements from your local government.</p>
</main>
)
}
What does this code do?
- Home() is the default page component. It renders at the root URL (/).
- The Tailwind classes (max-w-2xl mx-auto p-8) center the content and add padding so the page doesn't stretch edge-to-edge.
- Save app/page.tsx.
- Switch to your browser and check http://localhost:3000. You should see a "Town Bulletin Board" heading with the subtitle "Official announcements from your local government." on a clean white page.
Still seeing the default Next.js page?
- Make sure you saved app/page.tsx. Unsaved changes won't trigger a browser refresh.
- Try a hard refresh in your browser: Cmd+Shift+R (macOS) or Ctrl+Shift+R (Windows).
- Check that you edited app/page.tsx and not a different file. The file should be directly inside the app/ folder.
- Still stuck? Help me figure out why my page isn't updating.
✔️ Awesome, I've got everything!
Great. Double check you've saved app/page.tsx before moving on.
ⓧ I'd like to double check the full code
Here is the full app/page.tsx file. Make sure yours matches exactly.
export default function Home() {
return (
<main className="max-w-2xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-4">Town Bulletin Board</h1>
<p className="text-gray-600">Official announcements from your local government.</p>
</main>
)
}
Your project is scaffolded, Cache Components is on, and you have a clean starting page. Next up, you'll build the slow data source that makes caching feel like a superpower.
Build the Slow Data Source and Feel the Pain
Your Next.js project is running and the cacheComponents flag is enabled. Before you add caching, you need something worth caching.
In this step, you'll build a mock data source with an artificial 2-second delay to simulate a slow database. Then you'll display that data on the page so you can feel the exact pain that caching solves.
In this step, get ready to:
- Create a mock data store with announcement data and a deliberate 2-second delay.
- Display announcements on the page with no caching to experience the slow load.
Create the mock data store
Real apps fetch data from databases, but setting one up would distract from the caching concepts. Instead, you'll create an in-memory data store with a built-in delay so every fetch takes 2 seconds.
- In the VS Code Explorer sidebar, right-click on the app folder.
- Click New Folder and name it lib.
- Right-click on the new lib folder and click New File.
- Name the file data.ts and press Enter.
- Add the data store code by copying the following into data.ts:
type Announcement = {
id: string
title: string
content: string
author: string
createdAt: string
}
const announcements: Announcement[] = [
{
id: '1',
title: 'Town Hall Meeting This Friday',
content: 'Join us at 7 PM in the Community Center to discuss the new park renovation project.',
author: 'Mayor Office',
createdAt: '2026-06-20T10:00:00Z',
},
{
id: '2',
title: 'Road Closure on Main Street',
content: 'Main Street will be closed from June 25-28 for water line repairs.',
author: 'Public Works',
createdAt: '2026-06-18T14:30:00Z',
},
]
export async function getAnnouncements(): Promise<Announcement[]> {
// Simulate a slow database query
await new Promise((resolve) => setTimeout(resolve, 2000))
return [...announcements].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)
}
export async function addAnnouncement(
title: string,
content: string,
author: string
): Promise<Announcement> {
const newAnnouncement: Announcement = {
id: String(Date.now()),
title,
content,
author,
createdAt: new Date().toISOString(),
}
announcements.push(newAnnouncement)
return newAnnouncement
}
What does this code do?
- Announcement defines the shape of each announcement with an ID, title, content, author, and creation date.
- announcements is an in-memory array with two seed announcements. This replaces a real database so you can focus entirely on caching.
- getAnnouncements() waits 2 seconds before returning the sorted list. This setTimeout is the artificial delay that makes caching improvements obvious.
- addAnnouncement() pushes a new item into the array. You'll use this in a later step to publish announcements and bust the cache.
- Save data.ts.
You won't see any changes in the browser yet. The next substep wires this data into the page.
Seeing red underlines in VS Code?
- Make sure the file is saved at app/lib/data.ts, not at the project root. The lib folder must be inside app.
- Check for missing commas between the two objects in the announcements array. Each object should end with a trailing comma.
Help me fix TypeScript errors in my data.ts file.
✔️ Awesome, I've got everything!
Great. Double check you've saved data.ts before moving on.
ⓧ I'd like to double check the full code
type Announcement = {
id: string
title: string
content: string
author: string
createdAt: string
}
const announcements: Announcement[] = [
{
id: '1',
title: 'Town Hall Meeting This Friday',
content: 'Join us at 7 PM in the Community Center to discuss the new park renovation project.',
author: 'Mayor Office',
createdAt: '2026-06-20T10:00:00Z',
},
{
id: '2',
title: 'Road Closure on Main Street',
content: 'Main Street will be closed from June 25-28 for water line repairs.',
author: 'Public Works',
createdAt: '2026-06-18T14:30:00Z',
},
]
export async function getAnnouncements(): Promise<Announcement[]> {
// Simulate a slow database query
await new Promise((resolve) => setTimeout(resolve, 2000))
return [...announcements].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)
}
export async function addAnnouncement(
title: string,
content: string,
author: string
): Promise<Announcement> {
const newAnnouncement: Announcement = {
id: String(Date.now()),
title,
content,
author,
createdAt: new Date().toISOString(),
}
announcements.push(newAnnouncement)
return newAnnouncement
}
Display announcements with no caching
Now you'll connect the data store to the page. With cacheComponents enabled, any async component that accesses uncached data must be wrapped in a Suspense boundary. Without it, Next.js throws an error.
- Switch back to app/page.tsx in VS Code.
- Select all the existing code and replace it with the following:
import { Suspense } from 'react'
import { getAnnouncements } from '@/app/lib/data'
async function AnnouncementList() {
const announcements = await getAnnouncements()
const fetchedAt = new Date().toLocaleTimeString()
return (
<section>
<p className="text-sm text-gray-500 mb-4">Data fetched at: {fetchedAt}</p>
<ul className="space-y-4">
{announcements.map((item) => (
<li key={item.id} className="border rounded-lg p-4">
<h3 className="text-lg font-semibold">{item.title}</h3>
<p className="text-gray-700 mt-1">{item.content}</p>
<p className="text-sm text-gray-500 mt-2">
{item.author} · {new Date(item.createdAt).toLocaleDateString()}
</p>
</li>
))}
</ul>
</section>
)
}
export default function Home() {
return (
<main className="max-w-2xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-4">Town Bulletin Board</h1>
<p className="text-gray-600 mb-6">Official announcements from your local government.</p>
<Suspense fallback={<p className="text-gray-400">Loading announcements...</p>}>
<AnnouncementList />
</Suspense>
</main>
)
}
What does this code do?
- AnnouncementList is an async function component. It calls getAnnouncements() and waits 2 seconds for the response, then renders the list. It also records the time the data was fetched so you can see whether a response is fresh or cached.
- Suspense wraps the async component with a loading fallback. While AnnouncementList is waiting for data, the browser shows "Loading announcements..." instead of a blank page.
- The Home component renders the page heading and the Suspense-wrapped list. This is the uncached baseline. Every visit triggers the full 2-second wait.
- Save page.tsx.
- Return to your browser at http://localhost:3000 and reload the page.
- You should see "Loading announcements..." for about 2 seconds before the two announcements appear.
- Reload the page a few more times. Notice that the "Data fetched at" timestamp updates on every single reload. This confirms that no caching is happening.
Seeing an error instead of the loading text?
- If you see an error mentioning uncached data accessed outside of Suspense, make sure the <Suspense> wrapper is around <AnnouncementList /> inside the Home component.
- Check the import path. It should be @/app/lib/data with no file extension.
- Make sure data.ts is saved inside app/lib/, not somewhere else in the project.
Help me debug my announcement list page.
✔️ Awesome, I've got everything!
Great. Double check you've saved page.tsx before moving on.
ⓧ I'd like to double check the full code
import { Suspense } from 'react'
import { getAnnouncements } from '@/app/lib/data'
async function AnnouncementList() {
const announcements = await getAnnouncements()
const fetchedAt = new Date().toLocaleTimeString()
return (
<section>
<p className="text-sm text-gray-500 mb-4">Data fetched at: {fetchedAt}</p>
<ul className="space-y-4">
{announcements.map((item) => (
<li key={item.id} className="border rounded-lg p-4">
<h3 className="text-lg font-semibold">{item.title}</h3>
<p className="text-gray-700 mt-1">{item.content}</p>
<p className="text-sm text-gray-500 mt-2">
{item.author} · {new Date(item.createdAt).toLocaleDateString()}
</p>
</li>
))}
</ul>
</section>
)
}
export default function Home() {
return (
<main className="max-w-2xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-4">Town Bulletin Board</h1>
<p className="text-gray-600 mb-6">Official announcements from your local government.</p>
<Suspense fallback={<p className="text-gray-400">Loading announcements...</p>}>
<AnnouncementList />
</Suspense>
</main>
)
}
Why is the delay important?
The 2-second delay is not a bug. It is a deliberate teaching tool. In a real app, this latency comes from slow database queries, distant API calls, or heavy processing. By making the delay artificial and consistent, you get a measurable baseline to compare against once caching is in place.
Every single reload forces a 2-second wait. That is the uncached baseline. In the next step, you'll add three lines of code to make this page load instantly.
Make It Instant with Cache Components
You've felt the pain firsthand. Every page load forces your users to stare at a loading spinner for 2 seconds while the data source responds.
Next.js 16's Cache Components let you cache a component's rendered output so it only fetches data once. After that first load, the announcement list renders instantly from the cache. In this step, you'll extract the announcement list into its own cached component and see the speed difference.
In this step, get ready to:
- Extract the announcement list into a standalone cached component.
- Update the page to use the cached component and measure the speed difference.
Extract and cache the announcement list
Right now, the AnnouncementList function lives inline inside app/page.tsx. To cache it, you need to move it into its own file and add three caching directives.
- In the VS Code Explorer sidebar, right-click the app folder and select New Folder.
- Name the folder components.
- Right-click the new components folder and select New File.
- Name the file AnnouncementList.tsx.
- Paste the following code into AnnouncementList.tsx:
import { cacheLife, cacheTag } from 'next/cache'
import { getAnnouncements } from '@/app/lib/data'
export default async function AnnouncementList() {
// Enable caching for this component's rendered output
'use cache'
// Cache for up to 1 day, revalidate after 1 hour
cacheLife('hours')
// Tag this cache entry so it can be invalidated on demand
cacheTag('announcements')
const announcements = await getAnnouncements()
const fetchedAt = new Date().toLocaleTimeString()
return (
<section>
<p className="text-sm text-gray-500 mb-4">Data fetched at: {fetchedAt}</p>
<ul className="space-y-4">
{announcements.map((item) => (
<li key={item.id} className="border rounded-lg p-4">
<h3 className="text-lg font-semibold">{item.title}</h3>
<p className="text-gray-700 mt-1">{item.content}</p>
<p className="text-sm text-gray-500 mt-2">
{item.author} ·{' '}
{new Date(item.createdAt).toLocaleDateString()}
</p>
</li>
))}
</ul>
</section>
)
}
What does this code do?
Three lines at the top of the function do all the caching work:
- 'use cache' tells Next.js to cache this component's rendered output. After the first render, subsequent requests receive the cached HTML instead of re-running the function.
- cacheLife('hours') sets the cache lifetime using a built-in profile. Users see cached content for 5 minutes without checking the server. The server regenerates in the background after 1 hour. The cache expires completely after 1 day.
- cacheTag('announcements') labels this cache entry with a tag. You'll use this tag in the next step to invalidate the cache on demand when new content is published.
- Save AnnouncementList.tsx.
You won't see a change in the browser yet. The page still uses the old inline component. The next substep updates the page to import this new cached version.
Seeing a red squiggly line under the imports?
Make sure you saved AnnouncementList.tsx inside app/components/ and not directly inside app/. The import path @/app/lib/data resolves relative to the project root.
If you see an error about cacheLife or cacheTag not being found, confirm that cacheComponents: true is set in your next.config.ts.
Help me fix an import error in my AnnouncementList.tsx file
✔️ Awesome, I've got everything!
Great. Make sure you've saved AnnouncementList.tsx before moving on.
ⓧ I'd like to double check the full code
Here is the complete app/components/AnnouncementList.tsx file:
import { cacheLife, cacheTag } from 'next/cache'
import { getAnnouncements } from '@/app/lib/data'
export default async function AnnouncementList() {
'use cache'
cacheLife('hours')
cacheTag('announcements')
const announcements = await getAnnouncements()
const fetchedAt = new Date().toLocaleTimeString()
return (
<section>
<p className="text-sm text-gray-500 mb-4">Data fetched at: {fetchedAt}</p>
<ul className="space-y-4">
{announcements.map((item) => (
<li key={item.id} className="border rounded-lg p-4">
<h3 className="text-lg font-semibold">{item.title}</h3>
<p className="text-gray-700 mt-1">{item.content}</p>
<p className="text-sm text-gray-500 mt-2">
{item.author} ·{' '}
{new Date(item.createdAt).toLocaleDateString()}
</p>
</li>
))}
</ul>
</section>
)
}
Update the page to use the cached component
The page currently defines its own inline AnnouncementList function and wraps it in a Suspense boundary. Now that caching handles the heavy lifting, you can simplify the page dramatically.
- Open app/page.tsx and replace all of its contents with the following code:
import AnnouncementList from '@/app/components/AnnouncementList'
export default function Home() {
return (
<main className="max-w-2xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-4">Town Bulletin Board</h1>
<p className="text-gray-600 mb-6">Official announcements from your local government.</p>
{/* Cached component renders instantly after the first load */}
<AnnouncementList />
</main>
)
}
Why is there no Suspense wrapper?
Because AnnouncementList uses 'use cache', its rendered output is included in the static shell that Next.js prerenders. The component does not need to stream at request time. It already has a cached result ready to serve.
The first visit still takes 2 seconds because the cache is cold. Every visit after that is instant because Next.js serves the cached HTML directly.
- Save app/page.tsx.
- Switch to your browser and reload the page at http://localhost:3000. This first reload is a cold cache, so it takes about 2 seconds.
- Reload the page again. The page loads instantly this time.
- Check the "Data fetched at" timestamp. It stays frozen at the time of the first fetch, proving the cached version is being served.
Page still takes 2 seconds on every reload?
Make sure you are not doing a hard refresh (Cmd+Shift+R on macOS or Ctrl+Shift+R on Windows). A hard refresh bypasses the browser cache and can trigger a full re-render. Use a normal reload (Cmd+R or Ctrl+R) instead.
Also confirm that cacheComponents: true is still set in next.config.ts. Without it, the 'use cache' directive has no effect.
Help me figure out why my cached component is still slow on reload
✔️ Awesome, I've got everything!
Great. Make sure you've saved app/page.tsx before moving on.
ⓧ I'd like to double check the full code
Here is the complete app/page.tsx file:
import AnnouncementList from '@/app/components/AnnouncementList'
export default function Home() {
return (
<main className="max-w-2xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-4">Town Bulletin Board</h1>
<p className="text-gray-600 mb-6">Official announcements from your local government.</p>
<AnnouncementList />
</main>
)
}
Your announcements now load instantly from the cache. But what happens when someone publishes a new announcement? Users would see stale data until the cache expires on its own. Next up, you'll build a publish form that busts the cache on demand so new content appears immediately.
Bust the Cache When Content Changes
Your bulletin board now loads instantly thanks to caching. But what happens when an admin publishes a new announcement?
Right now, users would keep seeing stale cached data with no sign of the update. In this step, you'll fix this with a Server Action that invalidates the cache the moment new content is published.
In this step, get ready to:
- Create a server action that invalidates the announcement cache on demand.
- Build a publish form for adding new announcements.
- Wire the form into the page and verify that new content appears instantly.
Create the server action with updateTag
When an admin publishes a new announcement, you need to tell Next.js to throw away the old cached version and fetch fresh data. The updateTag function does exactly this by immediately invalidating any cache entry with the matching tag.
- Create a new file at app/lib/actions.ts and paste the following code:
'use server'
import { updateTag } from 'next/cache'
import { addAnnouncement } from '@/app/lib/data'
export async function publishAnnouncement(formData: FormData) {
// Extract form fields
const title = formData.get('title') as string
const content = formData.get('content') as string
const author = formData.get('author') as string
// Validate all fields are present
if (!title || !content || !author) {
throw new Error('All fields are required')
}
// Save the new announcement to the data store
await addAnnouncement(title, content, author)
// Immediately invalidate the cached announcement list
updateTag('announcements')
}
What does this code do?
- 'use server' marks this file as a Server Action module. Every exported function in this file can be called directly from a Client Component.
- publishAnnouncement reads the form data, validates it, saves the announcement, and then calls updateTag('announcements').
- updateTag('announcements') immediately invalidates the cache entry you tagged in the previous step. The next render waits for fresh data so the admin sees their change right away.
Why updateTag instead of revalidateTag?
Next.js offers two ways to bust a cache: updateTag and revalidateTag. The difference matters for admin workflows.
updateTag causes immediate invalidation and the action response includes a re-render with fresh data. The admin sees their change in the same request. revalidateTag uses stale-while-revalidate semantics, which means the admin might still see stale data after publishing.
- Save app/lib/actions.ts.
- You won't see a change yet. The next substep creates the form that calls this action.
Seeing red squiggles in the editor?
- Check that the import path is @/app/lib/data with no typos. The @/ prefix maps to the project root.
- Make sure 'use server' is the very first line of the file, with nothing before it.
Why is my actions.ts file showing errors?
Build the publish form
The server action is ready, but you need a form for admins to type their announcements into. This form is a Client Component because it handles interactive form state in the browser.
- Create a new file at app/components/PublishForm.tsx and paste the following code:
'use client'
import { useRef } from 'react'
import { publishAnnouncement } from '@/app/lib/actions'
export default function PublishForm() {
// Track the form element so we can reset it after submission
const formRef = useRef<HTMLFormElement>(null)
async function handleSubmit(formData: FormData) {
// Call the server action, then clear the form
await publishAnnouncement(formData)
formRef.current?.reset()
}
return (
<section className="mt-8 border-t pt-6">
<h2 className="text-xl font-bold mb-4">Publish New Announcement</h2>
<form ref={formRef} action={handleSubmit} className="space-y-3">
<input
name="title"
placeholder="Announcement title"
required
className="w-full border rounded px-3 py-2"
/>
<textarea
name="content"
placeholder="Announcement content"
required
rows={3}
className="w-full border rounded px-3 py-2"
/>
<input
name="author"
placeholder="Author / Department"
required
className="w-full border rounded px-3 py-2"
/>
<button
type="submit"
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
Publish
</button>
</form>
</section>
)
}
What does this code do?
- 'use client' makes this a Client Component so it can handle interactive form state in the browser.
- useRef holds a reference to the form element. After submission, formRef.current?.reset() clears all the input fields so the admin can immediately write another announcement.
- The form's action attribute receives handleSubmit, which calls your server action directly. Next.js handles the server roundtrip behind the scenes.
- Save app/components/PublishForm.tsx.
- You won't see this form yet. The next substep adds it to the page.
Seeing type errors in PublishForm?
- Make sure 'use client' is the very first line of the file. Nothing can come before it, not even a comment.
- Check that the import path is @/app/lib/actions, not @/app/lib/action (missing the s).
Why is my PublishForm showing errors?
Add the form to the page
- Switch back to app/page.tsx and replace its contents with the following code:
import AnnouncementList from '@/app/components/AnnouncementList'
import PublishForm from '@/app/components/PublishForm'
export default function Home() {
return (
<main className="max-w-2xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-4">Town Bulletin Board</h1>
<p className="text-gray-600 mb-6">Official announcements from your local government.</p>
{/* Cached announcement list */}
<AnnouncementList />
{/* Admin form to publish new announcements */}
<PublishForm />
</main>
)
}
What changed?
Two additions: an import for PublishForm and the <PublishForm /> component rendered below the cached AnnouncementList. The page now has both the cached announcement list and the admin form.
- Save app/page.tsx and refresh your browser.
- You should see a Publish New Announcement section below the announcement list with three input fields and a blue Publish button.
Now test the full flow.
- Type a title in the Announcement title field (for example, Free Concert in the Park).
- Type some content in the Announcement content field (for example, Join us Saturday at 6 PM for live music.).
- Type an author name in the Author / Department field (for example, Parks Department).
- Click Publish.
Your new announcement appears immediately in the list above the form. The "Data fetched at" timestamp updates to the current time, proving fresh data was fetched.
Announcement not appearing after clicking Publish?
- Make sure app/lib/actions.ts starts with 'use server' as the very first line. Without it, Next.js does not recognize the function as a Server Action.
- Check that the tag string in updateTag('announcements') matches the tag string in cacheTag('announcements') inside AnnouncementList.tsx. They must be identical.
- Open the terminal running your dev server. If you see an error, fix it and save again.
Why isn't my published announcement showing up?
✔️ Awesome, I've got everything!
Great. Make sure all three files are saved before moving on.
ⓧ I'd like to double check the full code
Here is the full code for all three files. Compare each one against yours.
'use server'
import { updateTag } from 'next/cache'
import { addAnnouncement } from '@/app/lib/data'
export async function publishAnnouncement(formData: FormData) {
const title = formData.get('title') as string
const content = formData.get('content') as string
const author = formData.get('author') as string
if (!title || !content || !author) {
throw new Error('All fields are required')
}
await addAnnouncement(title, content, author)
updateTag('announcements')
}
'use client'
import { useRef } from 'react'
import { publishAnnouncement } from '@/app/lib/actions'
export default function PublishForm() {
const formRef = useRef<HTMLFormElement>(null)
async function handleSubmit(formData: FormData) {
await publishAnnouncement(formData)
formRef.current?.reset()
}
return (
<section className="mt-8 border-t pt-6">
<h2 className="text-xl font-bold mb-4">Publish New Announcement</h2>
<form ref={formRef} action={handleSubmit} className="space-y-3">
<input
name="title"
placeholder="Announcement title"
required
className="w-full border rounded px-3 py-2"
/>
<textarea
name="content"
placeholder="Announcement content"
required
rows={3}
className="w-full border rounded px-3 py-2"
/>
<input
name="author"
placeholder="Author / Department"
required
className="w-full border rounded px-3 py-2"
/>
<button
type="submit"
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
Publish
</button>
</form>
</section>
)
}
import AnnouncementList from '@/app/components/AnnouncementList'
import PublishForm from '@/app/components/PublishForm'
export default function Home() {
return (
<main className="max-w-2xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-4">Town Bulletin Board</h1>
<p className="text-gray-600 mb-6">Official announcements from your local government.</p>
<AnnouncementList />
<PublishForm />
</main>
)
}
Your bulletin board now caches for speed and invalidates on demand for freshness. Next up, you'll mix cached and dynamic content on the same page using Suspense and Partial Prerendering.
Mix Cached and Dynamic Content with Suspense
Your bulletin board now serves cached announcements instantly and invalidates the cache the moment an admin publishes. That covers the two most critical caching operations: reading fast and writing fresh.
Real pages often need a mix of cached and live content on the same screen. In this step, you'll use Suspense and Partial Prerendering to show cached announcements alongside a live server timestamp, all on one page.
In this step, get ready to:
- Create a dynamic component that opts out of caching entirely.
- Wrap it in a Suspense boundary so cached and dynamic content coexist on one page.
Create a dynamic component
Not everything on a page should be cached. A live server timestamp needs to update on every request. Next.js provides the connection() function to tell a component it needs a live request instead of a cached response.
- Create a new file at app/components/CurrentTime.tsx inside your town-bulletin project.
- Add the following code to create a component that shows the current server time on every request:
import { connection } from 'next/server'
export default async function CurrentTime() {
// Tell Next.js this component needs a live request
await connection()
const now = new Date().toLocaleTimeString()
return (
<p className="text-sm text-gray-400 mt-6">
Page checked at: {now} (dynamic, never cached)
</p>
)
}
What does this code do?
connection() is imported from next/server. It tells Next.js that this component depends on a live request. Without it, new Date() would be evaluated at build time and frozen forever.
Because this component awaits a live connection, Next.js excludes it from the static shell. It streams in at request time while cached content appears instantly.
- Save app/components/CurrentTime.tsx.
- You won't see a change yet. The next substep adds this component to the page.
Seeing a red squiggle under the import?
Make sure the import reads next/server, not next/cache. The connection function lives in a different module from the caching utilities.
Also confirm the file is saved inside app/components/, matching the other components you've already created.
Help me fix an import error in my CurrentTime component.
✔️ Awesome, I've got everything!
Great. Make sure you've saved CurrentTime.tsx before moving on.
ⓧ I'd like to double check the full code
import { connection } from 'next/server'
export default async function CurrentTime() {
await connection()
const now = new Date().toLocaleTimeString()
return (
<p className="text-sm text-gray-400 mt-6">
Page checked at: {now} (dynamic, never cached)
</p>
)
}
Add Suspense streaming to the page
Your page currently renders two components: the cached AnnouncementList and the interactive PublishForm. Now you'll add CurrentTime wrapped in a Suspense boundary so it streams in dynamically while everything else loads instantly.
- Replace the entire contents of app/page.tsx with the following code:
import { Suspense } from 'react'
import AnnouncementList from '@/app/components/AnnouncementList'
import PublishForm from '@/app/components/PublishForm'
import CurrentTime from '@/app/components/CurrentTime'
export default function Home() {
return (
<main className="max-w-2xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-4">Town Bulletin Board</h1>
<p className="text-gray-600 mb-6">
Official announcements from your local government.
</p>
{/* Cached: included in static shell, loads instantly */}
<AnnouncementList />
{/* Dynamic: streams at request time */}
<Suspense fallback={<p className="text-sm text-gray-300 mt-6">Loading timestamp...</p>}>
<CurrentTime />
</Suspense>
<PublishForm />
</main>
)
}
What does this code do?
- AnnouncementList uses 'use cache', so its output is included in the static shell. It loads instantly after the first visit.
- CurrentTime awaits connection(), so Next.js excludes it from the static shell. The Suspense fallback appears in the initial HTML while the real timestamp streams in at request time.
- PublishForm is a Client Component that handles interactivity in the browser.
This single page now has three rendering modes coexisting: static (heading and subtitle), cached (announcements), and dynamic (timestamp).
What is Partial Prerendering?
Partial Prerendering (PPR) is enabled automatically when cacheComponents is true. Next.js generates a static shell from your cached and static content, then streams dynamic components into Suspense boundaries at request time.
The result: the browser receives the bulk of the page instantly. Dynamic pieces fill in moments later without blocking the initial render.
- Save app/page.tsx.
- Reload the page at http://localhost:3000 multiple times. The announcements and their "Data fetched at" timestamp stay frozen (cached). The "Page checked at" timestamp updates on every reload (dynamic).
Timestamp not updating on reload?
- Check that CurrentTime is wrapped in a <Suspense> boundary in page.tsx. Without Suspense, the dynamic component cannot stream in separately from the static shell.
- Confirm that CurrentTime.tsx includes await connection() before new Date(). Without it, the timestamp gets evaluated at build time and stays frozen.
- Make sure you imported Suspense from react at the top of page.tsx.
Help me debug why my dynamic timestamp isn't updating on page reload.
✔️ Awesome, I've got everything!
Great. Make sure you've saved page.tsx before continuing.
ⓧ I'd like to double check the full code
import { Suspense } from 'react'
import AnnouncementList from '@/app/components/AnnouncementList'
import PublishForm from '@/app/components/PublishForm'
import CurrentTime from '@/app/components/CurrentTime'
export default function Home() {
return (
<main className="max-w-2xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-4">Town Bulletin Board</h1>
<p className="text-gray-600 mb-6">
Official announcements from your local government.
</p>
{/* Cached: included in static shell, loads instantly */}
<AnnouncementList />
{/* Dynamic: streams at request time */}
<Suspense fallback={<p className="text-sm text-gray-300 mt-6">Loading timestamp...</p>}>
<CurrentTime />
</Suspense>
<PublishForm />
</main>
)
}
Secret mission
Custom Cache Profiles for Different Content Types
Not all content deserves the same cache lifetime. Breaking news should refresh every few seconds, while archived ordinances can cache for weeks. In this mission, you will create custom cacheLife profiles and apply them to different content categories, then see how short-lived profiles automatically become dynamic holes in Next.js.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs or cloud resources to worry about. Decide whether to keep your project files, pause the dev server, or delete everything.
Resources you used:
- The town-bulletin project folder on your Desktop.
- The Next.js dev server running at http://localhost:3000.
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with caching profiles.
- Your dev server continues running at http://localhost:3000.
- Your town-bulletin folder stays on your Desktop.
- Node.js remains installed on your machine.
Pause - I'll come back to this later
Stop the dev server to free up the terminal and the port. Your project files stay on disk so you can pick up where you left off.
- Switch to the terminal where the dev server is running.
- Press Ctrl+C to stop the server.
- To resume later, open a terminal in the town-bulletin folder and run npm run dev.
Delete - I don't want to use this again
Remove the project folder entirely. This deletes all the code you wrote in this project.
- Switch to the terminal where the dev server is running and press Ctrl+C to stop it.
- Navigate to your Desktop and delete the project folder by running these commands:
macOS
cd ~/Desktop
rm -rf town-bulletin
Windows
cd ~\Desktop
Remove-Item -Recurse -Force town-bulletin
- Confirm the folder is gone by running ls (macOS) or dir (Windows). You should no longer see town-bulletin in the output.
Nice Work!
Nice Work!
You just built a Town Bulletin Board app that proves you can make pages load instantly with Next.js 16's Cache Components system. You saw a 2-second page load drop to zero, busted the cache on demand so admins see fresh content immediately, and mixed cached and dynamic content on a single page.
You've learned how to:
- Used the 'use cache' directive with cacheLife('hours') and cacheTag('announcements') to turn a 2-second page load into an instant one.
- Implemented on-demand cache invalidation with updateTag('announcements') in a Server Action, solving the problem where admin updates are invisible to users until the cache expires.
- Mixed cached and dynamic content on the same page using Suspense streaming and Partial Prerendering, giving you three rendering modes (static, cached, dynamic) coexisting in one route.
- Secret Mission: Defined custom cacheLife profiles (breakingNews and archived) to apply different cache strategies based on content urgency.
Ready to quiz yourself?