Tutorial: Build a Prompt Library with Next.js Server Components
Build a Next.js prompt library with Server Components, validated Server Actions, cached reads with tags, and a streamed preview with a Stop button.
On this page· What you will build
Why this matters
Every team that ships AI features ends up with prompts scattered across code, docs, and chat threads. A small internal prompt library is one of the most common first AI tools a full-stack engineer builds: save a template, try it with real input, iterate. Building one exercises everything in the React and Next.js article: Server Components that read data, Client Components only where there is interaction, Server Actions that validate, and a Route Handler that streams. It also introduces how a freshly created Next.js app caches data, which has changed in recent versions.
What you will build#
- A prompts page rendered by an async Server Component.
- A create form that calls a Server Action, shows a pending state, and displays field errors without losing what the user typed.
- Delete buttons that are plain forms, so they work before JavaScript loads.
- A detail page with a streamed preview: fill the template's
{{input}}, watch the reply arrive word by word, and stop it mid-stream. - Cached reads that update immediately after every write.
Hover or tap a component to see what it does.
Show text descriptionHide text description
Only the form and the preview run in the browser. Reading, validating, writing, and generating all happen on the server.
- User
- Page: Prompts page, an async Server Component: reads prompts directly on the server and sends HTML, no client JavaScript.
- Form: PromptForm, a Client Component: submits FormData to a Server Action with useActionState, and shows pending state and field errors.
- Preview: Preview, a Client Component: posts the input, renders the streamed reply as it arrives, and aborts on Stop.
- Actions: Server Actions createPrompt and removePrompt: public endpoints that validate with Zod, write, and call updateTag.
- Route: Route Handler for POST /api/preview: validates the request, fills the template, and streams the reply; stops when the request is aborted.
- Store: Prompt store, an in-memory stand-in for a database. Reads use "use cache" with the prompts tag.
- Fake model: An async generator that yields words; becomes a real model stream later in the wiki.
Read. The page reads prompts on the server from the cached store.
Create. The form posts FormData to a Server Action, which validates, writes, and expires the prompts tag.
Preview. The preview posts the input to a Route Handler and renders the streamed reply.
You need Node.js 22 or later. Read React and Next.js for AI apps first. No API key: the "model" is a stand-in that streams words, and the LLM sections swap in a real one.
1. Create the app#
Generate the project
npx create-next-app@latest prompt-library --yes cd prompt-library npm install zod--yesaccepts the recommended defaults: TypeScript, the App Router, Tailwind CSS, ESLint, Turbopack, and the@/*import alias.Look at next.config.ts
next.config.ts const nextConfig: NextConfig = { cacheComponents: true, partialPrefetching: true, // ... };New apps have Cache Components turned on. Step 4 explains what that changes and why it matters for this app.
Start the dev server
npm run devKeep it running; every file you add below is picked up automatically.
Replace the starter home page with a redirect, so http://localhost:3000 opens the library:
import { redirect } from "next/navigation";
export default function Home() {
redirect("/prompts");
}2. The data layer#
A prompt is a title and a template with an {{input}} placeholder. The store is an in-memory array standing in for a database table, behind async functions shaped like real queries, so swapping in Postgres later changes this file only.
import { cacheLife, cacheTag } from "next/cache";
export interface Prompt {
id: string;
title: string;
template: string;
createdAt: Date;
}
// In-memory stand-in for a database table. It resets when the dev server restarts;
// the Databases tutorial replaces it with Postgres.
const prompts: Prompt[] = [
{
id: crypto.randomUUID(),
title: "Summarize a support ticket",
template: "Summarize this support ticket in two sentences for the on-call engineer:\n\n{{input}}",
createdAt: new Date(),
},
];
// Reads are cached and tagged, so pages can be served from cache until a write calls updateTag("prompts").
export async function listPrompts(): Promise<Prompt[]> {
"use cache";
cacheLife("max");
cacheTag("prompts");
return [...prompts].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
export async function getPrompt(id: string): Promise<Prompt | undefined> {
"use cache";
cacheLife("max");
cacheTag("prompts");
return prompts.find((p) => p.id === id);
}
export async function addPrompt(input: { title: string; template: string }): Promise<Prompt> {
const prompt = { id: crypto.randomUUID(), ...input, createdAt: new Date() };
prompts.push(prompt);
return prompt;
}
export async function deletePrompt(id: string): Promise<void> {
const index = prompts.findIndex((p) => p.id === id);
if (index !== -1) prompts.splice(index, 1);
}
/** Put the user's text where the template says {{input}}. */
export function fillTemplate(template: string, input: string): string {
return template.replaceAll("{{input}}", input);
}"use cache" makes Next.js store the function's result, keyed by its arguments. cacheLife("max") says the result may be kept for a long time, and cacheTag("prompts") labels it so a write can expire it on demand. The reads are cached; the writes are not, and the writes are what expire the cache.
3. Server Actions: validate, write, expire#
Server Actions are functions the browser can call, which makes them public HTTP endpoints. Validate every input inside them, exactly as you would in an API route.
"use server";
import { updateTag } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { addPrompt, deletePrompt } from "@/lib/prompts";
const PromptInput = z.object({
title: z.string().trim().min(1, "Give the prompt a title").max(80, "Keep the title under 80 characters"),
template: z
.string()
.trim()
.min(1, "Write the prompt template")
.max(4_000, "Keep the template under 4,000 characters")
.refine((t) => t.includes("{{input}}"), "Include {{input}} where the user's text should go"),
});
export interface FormState {
errors: { title?: string[]; template?: string[] };
/** Echoed back on error, because React resets the form after every action. */
values: { title: string; template: string };
}
// Server Actions are public HTTP endpoints: validate (and, in a real app, authorize) inside every one.
export async function createPrompt(_prev: FormState, formData: FormData): Promise<FormState> {
const values = { title: String(formData.get("title") ?? ""), template: String(formData.get("template") ?? "") };
const parsed = PromptInput.safeParse(values);
if (!parsed.success) return { errors: z.flattenError(parsed.error).fieldErrors, values };
const prompt = await addPrompt(parsed.data);
updateTag("prompts"); // expire every cached read tagged "prompts", so the user sees their change
redirect(`/prompts/${prompt.id}`);
}
export async function removePrompt(id: string): Promise<void> {
const parsed = z.uuid().safeParse(id);
if (!parsed.success) return;
await deletePrompt(parsed.data);
updateTag("prompts");
}Three details are easy to miss:
z.flattenError(...).fieldErrorsturns Zod issues into{ title: [...], template: [...] }, one list per field, which is exactly what a form needs.valuesis returned on error. In React 19, a form is reset after its action runs. Without echoing the values back, a validation error would also wipe what the user typed.updateTag("prompts")expires the cached reads immediately, so the redirect lands on a page that already includes the new prompt. It is the read-your-own-writes version of revalidation, and it only works inside Server Actions.
4. Pages and Cache Components#
With Cache Components enabled, Next.js prerenders as much of every page as it can at build time into a static shell, and requires you to be explicit about anything that can only be known at request time. The rule is short:
- Data from a
"use cache"function can be part of the static shell. - Anything that needs the request, such as
params,searchParams,cookies(),headers(), or uncached data, must render inside a<Suspense>boundary. Its fallback goes in the shell, and the content streams in.
Forget the boundary and the build stops with "Next.js encountered uncached or runtime data during prerendering", naming the route. That is a feature: it tells you exactly which part of the page makes users wait.
The list page needs no request data, so it can be prerendered completely:
import Link from "next/link";
import { listPrompts } from "@/lib/prompts";
import { removePrompt } from "./actions";
import { PromptForm } from "./prompt-form";
import { SubmitButton } from "./submit-button";
// An async Server Component: it reads the data directly. No API route, no useEffect, no loading flash.
export default async function PromptsPage() {
const prompts = await listPrompts();
return (
<main className="mx-auto flex w-full max-w-2xl flex-col gap-6 p-6">
<h1 className="text-2xl font-bold">Prompt library</h1>
<PromptForm />
<ul className="flex flex-col gap-2">
{prompts.map((prompt) => (
<li key={prompt.id} className="flex items-center justify-between gap-4 rounded border p-3">
<Link href={`/prompts/${prompt.id}`} className="font-medium underline-offset-4 hover:underline">
{prompt.title}
</Link>
{/* bind() fixes the first argument; the form supplies the rest. Still a plain form: it works before hydration. */}
<form action={removePrompt.bind(null, prompt.id)}>
<SubmitButton label="Delete" pendingLabel="Deleting…" className="text-sm text-red-600 disabled:opacity-50" />
</form>
</li>
))}
</ul>
{prompts.length === 0 ? <p className="text-gray-500">No prompts yet. Save your first one above.</p> : null}
</main>
);
}removePrompt.bind(null, prompt.id) creates an action with the ID already filled in. The Server Component can do this because the bound ID is plain, serializable data. The delete button is a tiny Client Component that only exists to show a pending label:
"use client";
import { useFormStatus } from "react-dom";
/** A submit button that knows when its parent form's action is running. */
export function SubmitButton({ label, pendingLabel, className }: { label: string; pendingLabel: string; className?: string }) {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending} className={className}>
{pending ? pendingLabel : label}
</button>
);
}useFormStatus reads the status of the parent <form>, which is why it must live in a child component, not in the component that renders the form.
5. The create form#
The form is a Client Component because it shows state that changes: pending and errors. useActionState wires it to the action and keeps the latest returned state.
"use client";
import { useActionState } from "react";
import { createPrompt, type FormState } from "./actions";
const initialState: FormState = { errors: {}, values: { title: "", template: "" } };
export function PromptForm() {
const [state, formAction, pending] = useActionState(createPrompt, initialState);
return (
<form action={formAction} className="flex flex-col gap-3 rounded-lg border p-4">
<label className="flex flex-col gap-1">
<span className="font-medium">Title</span>
<input name="title" defaultValue={state.values.title} maxLength={80} className="rounded border px-2 py-1" aria-invalid={!!state.errors.title} />
{state.errors.title ? <span role="alert" className="text-sm text-red-600">{state.errors.title[0]}</span> : null}
</label>
<label className="flex flex-col gap-1">
<span className="font-medium">Template</span>
<textarea
name="template"
defaultValue={state.values.template}
rows={4}
placeholder="Rewrite this paragraph for a five-year-old: {{input}}"
className="rounded border px-2 py-1 font-mono text-sm"
aria-invalid={!!state.errors.template}
/>
{state.errors.template ? <span role="alert" className="text-sm text-red-600">{state.errors.template[0]}</span> : null}
</label>
<button type="submit" disabled={pending} className="self-start rounded bg-black px-3 py-1.5 text-white disabled:opacity-50">
{pending ? "Saving…" : "Save prompt"}
</button>
</form>
);
}Checkpoint. Open http://localhost:3000. Submit the form with an empty title and the template Translate to French. You should see both errors, "Give the prompt a title" and "Include {{input}} where the user's text should go", with your template text still in the box. Fix both and submit: you land on /prompts/<id>, which does not exist yet.
6. The streaming Route Handler#
The preview needs full control of the response body, which is the job of a Route Handler. It validates the request, fills the template, and streams the reply from a stand-in model:
import { setTimeout as sleep } from "node:timers/promises";
/**
* Stand-in for a streaming model call: yields a reply word by word.
* In the LLM App Engineering section, this becomes anthropic.messages.stream().
*/
export async function* fakeModel(prompt: string, signal: AbortSignal): AsyncGenerator<string> {
const reply =
`(Simulated reply: no model was called.) Your prompt is ${prompt.split(/\s+/).length} words long. ` +
`A real model would answer it; this one reads it back: ${prompt}`;
for (const word of reply.split(" ")) {
await sleep(60, undefined, { signal }); // rejects as soon as the signal aborts
yield `${word} `;
}
}import { z } from "zod";
import { fakeModel } from "@/lib/fake-model";
import { fillTemplate, getPrompt } from "@/lib/prompts";
const Body = z.object({
promptId: z.uuid(),
input: z.string().trim().min(1).max(2_000),
});
export async function POST(request: Request) {
const parsed = Body.safeParse(await request.json().catch(() => null));
if (!parsed.success) return Response.json({ error: "Send a promptId and some input text" }, { status: 400 });
const prompt = await getPrompt(parsed.data.promptId);
if (!prompt) return Response.json({ error: "Prompt not found" }, { status: 404 });
const text = fillTemplate(prompt.template, parsed.data.input);
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
try {
// request.signal aborts when the browser disconnects or the user presses Stop.
for await (const chunk of fakeModel(text, request.signal)) controller.enqueue(encoder.encode(chunk));
controller.close();
} catch (error) {
if (request.signal.aborted) return; // nobody is listening any more; stop generating
controller.error(error);
}
},
});
return new Response(stream, { headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-cache, no-transform" } });
}Passing request.signal all the way into the generator is what makes Stop save money once this is a real model: the generation ends on the server, not just on the screen. Only GET Route Handlers take part in prerendering, so this POST handler always runs per request.
7. The detail page and the preview#
The detail page reads params, which only exists at request time, so that part goes inside <Suspense>. The rest of the page, here the back link, is in the static shell and appears instantly.
import Link from "next/link";
import { notFound } from "next/navigation";
import { Suspense } from "react";
import { getPrompt } from "@/lib/prompts";
import { Preview } from "./preview";
// The page itself renders instantly; only the part that needs the URL's id waits, inside Suspense.
export default function PromptPage({ params }: PageProps<"/prompts/[id]">) {
return (
<main className="mx-auto flex w-full max-w-2xl flex-col gap-6 p-6">
<Link href="/prompts" className="text-sm underline-offset-4 hover:underline">
← All prompts
</Link>
<Suspense fallback={<p className="text-gray-500">Loading prompt…</p>}>
<PromptDetail params={params} />
</Suspense>
</main>
);
}
async function PromptDetail({ params }: Pick<PageProps<"/prompts/[id]">, "params">) {
const { id } = await params;
const prompt = await getPrompt(id);
if (!prompt) notFound();
return (
<>
<h1 className="text-2xl font-bold">{prompt.title}</h1>
<pre className="whitespace-pre-wrap rounded bg-gray-100 p-3 text-sm dark:bg-gray-900">{prompt.template}</pre>
{/* Only the interactive part is a Client Component, and it receives plain data. */}
<Preview promptId={prompt.id} />
</>
);
}PageProps<"/prompts/[id]"> is a global type that Next.js generates from your routes, so params is typed as Promise<{ id: string }> without writing it by hand. Note that params is a promise: you pass it down unresolved and await it inside the boundary.
The preview is the one genuinely interactive piece. It keeps the text, the status, and the error in state, and the AbortController in a ref, because changing the controller should not re-render anything.
"use client";
import { useRef, useState } from "react";
type Status = "idle" | "streaming" | "done" | "stopped" | "error";
export function Preview({ promptId }: { promptId: string }) {
const [input, setInput] = useState("");
const [output, setOutput] = useState("");
const [status, setStatus] = useState<Status>("idle");
const [error, setError] = useState<string | null>(null);
// The controller changes nothing on screen, so it lives in a ref, not in state.
const abortRef = useRef<AbortController | null>(null);
async function run() {
const controller = new AbortController();
abortRef.current = controller;
setOutput("");
setError(null);
setStatus("streaming");
try {
const res = await fetch("/api/preview", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ promptId, input }),
signal: controller.signal,
});
if (!res.ok || !res.body) {
const body = (await res.json().catch(() => null)) as { error?: string } | null;
throw new Error(body?.error ?? `Request failed (${res.status})`);
}
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
for (;;) {
const { value, done } = await reader.read();
if (done) break;
setOutput((text) => text + value); // functional update: never mutate, never use a stale value
}
setStatus("done");
} catch (e) {
if (controller.signal.aborted) return setStatus("stopped"); // the user pressed Stop; keep the partial text
setError(e instanceof Error ? e.message : "Something went wrong");
setStatus("error");
}
}
return (
<section className="flex flex-col gap-3">
<h2 className="text-lg font-semibold">Try it</h2>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
rows={3}
aria-label="Input for {{input}}"
placeholder="Text that replaces {{input}}"
className="rounded border px-2 py-1"
/>
<div className="flex items-center gap-3">
{status === "streaming" ? (
<button type="button" onClick={() => abortRef.current?.abort()} className="rounded border px-3 py-1.5">
Stop
</button>
) : (
<button type="button" onClick={run} disabled={!input.trim()} className="rounded bg-black px-3 py-1.5 text-white disabled:opacity-50">
Run preview
</button>
)}
<span className="text-sm text-gray-500">{status === "idle" ? "" : status}</span>
</div>
{error ? <p role="alert" className="text-red-600">{error}</p> : null}
<output aria-live="polite" aria-busy={status === "streaming"} className="min-h-24 whitespace-pre-wrap rounded border p-3">
{output}
</output>
</section>
);
}setOutput((text) => text + value) uses the functional form of the state setter. Chunks arrive faster than React re-renders; reading output directly would use a stale value and drop text.
Checkpoint. Save a prompt such as Translate to French: {{input}}, type some text into the preview, and press Run preview. The reply appears word by word. Press Stop halfway: the text stops growing, the partial reply stays, and the status reads stopped. Go back to the list: your prompt is there, and Delete removes it immediately. Open the deleted prompt's URL again and you get the 404 page.
8. Build for production#
npm run buildRoute (app) Revalidate Expire
┌ ○ /
├ ○ /_not-found
├ ƒ /api/preview
├ ○ /prompts 30d 1y
└ /prompts/[id]
└ ◐ /prompts/[id]
○ (Static) prerendered as static content
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content
ƒ (Dynamic) server-rendered on demandThe build output is a summary of your rendering decisions. /prompts is fully static: it comes from cached data, kept for up to 30 days unless updateTag expires it sooner. /prompts/[id] is a partial prerender: the shell is static, and the prompt streams in. /api/preview runs on every request. Run npm start and repeat the checkpoint against the production build.
Common mistakes#
- Marking the whole page
"use client". Only the form, the delete button, and the preview need the browser. Everything else stays on the server and ships no JavaScript. - Validating only in the browser.
requiredandmaxLengthimprove the experience; they protect nothing. Anyone can call a Server Action directly. - Losing form input on errors. React resets forms after an action. Return the submitted values with the errors and use them as
defaultValue. - Reading
paramsoutside<Suspense>. With Cache Components the build fails. Push the request-dependent part into a child component inside a boundary. - Stopping the stream only on the client. If the Route Handler ignores
request.signal, the server keeps generating (and, with a real model, paying) after the user pressed Stop. - Using
output + valuein a streaming loop. Use the functional setter; the closure'soutputis stale after the first chunk.
Try it
Extend the prompt library
- Add an edit page at
/prompts/[id]/editwith anupdatePromptServer Action that reusesPromptInput, and callupdateTag("prompts")after saving. - Show a character count under the template that updates as you type. Decide which component has to become a Client Component, and keep it as small as possible.
- Support several placeholders (
{{input}},{{tone}}): extract them with a regex, render one field per placeholder in the preview, and validate on the server that every placeholder has a value. - Add an
app/prompts/[id]/not-found.tsxwith a friendlier message and a link back to the list.
Check your understanding
Key takeaways
- Server Components read data directly; add
"use client"only to the leaves that hold state or handle events. - Server Actions are public endpoints: validate with Zod inside them, return field errors and the submitted values, and expire the cache after writes.
- With Cache Components (the default for new apps), cache reads with
"use cache"andcacheTag, callupdateTagafter mutations, and put request-time data such asparamsinside<Suspense>. - Stream with a Route Handler, pass
request.signalto the generator, and use functional state updates for streamed text. - Read the build output: it shows which routes are static, partially prerendered, or dynamic.
Next steps#
Finished this article? Track it to see your progress on the roadmap.
Related articles
- React and Next.js for AI Apps: Components, Hooks, and Server ComponentsReact and Next.js App Router concepts behind every AI interface: hooks, Server and Client Components, Server Actions, Route Handlers, and data fetching.
- Streaming LLM Responses to a React UI with Next.js and SSEStream Claude responses token by token from a Next.js Route Handler to a React client over Server-Sent Events, with stop, retry, error handling, and accessible updates.