Part 3 of a 4-part series on building with GraphQL, Strapi v5, and Next.js 16. Each part builds directly on the project from the previous post, so keep an eye out as we release them:
Note + Tag model, middlewares and policies, custom queries, and custom mutations.Already have the Part 2 backend running at http://localhost:1337/graphql? You are in the right place.
TL;DR
starter-template/, replace its placeholder Apollo stub with a real Apollo client for React Server Components, and then add one gql document at a time to lib/graphql.ts. Each step swaps one page's placeholder import for a real query(...) call.notes, note, tags, searchNotes, notesByTag, noteStats, createNote, updateNote, togglePin, and archiveNote. One page per operation, one GraphQL document per page.http://localhost:1337/graphql. The examples below assume the Note + Tag schema with Markdown content, enum tag colors, the three custom queries (searchNotes, noteStats, notesByTag), and the two custom mutations (togglePin, archiveNote).starter-template/ directory is available.This post is about reading and writing data. Authentication, JWT cookies, route protection, and per-user ownership all live in Part 4.
By the end you will have the following routes, each powered by exactly one GraphQL operation:
| Route | Operation | Kind |
|---|---|---|
/ | notes | Shadow CRUD list |
/notes/[documentId] | note | Shadow CRUD fetch |
/notes/new | createNote | Shadow CRUD mutation |
/notes/[documentId]/edit | updateNote | Shadow CRUD mutation |
/search?q=... | searchNotes | Custom query (Part 2 Step 9.2) |
/tags/[slug] | notesByTag | Custom query (Part 2 Step 9.4) |
/stats | noteStats | Custom query (Part 2 Step 9.3) |
Inline buttons on /notes/[documentId] | togglePin, archiveNote | Custom mutations (Part 2 Step 10) |
Two pieces of the stack are worth a short introduction before we start wiring things up. If you have used either before, skim past.
Next.js. A React framework that adds a file-system router, a build pipeline, and a server runtime on top of React. The features used here all live in the App Router (the default since Next.js 13). Every directory under app/ is a route. Every page.tsx is a Server Component by default. Every function tagged with "use server" becomes a Server Action. Server Components render on the server and stream HTML to the browser, which means page.tsx can read from Strapi without sending any GraphQL client code to the browser. Server Actions are the matching feature for writes: a Server Component can pass a server-only function as a prop, and a <form> can run that function via action={...}. Next.js 16 keeps this model and is what the starter is built on. The "Getting Started" guide is the right entry point if you have not used the App Router before.
Apollo Client. A GraphQL client for JavaScript. It builds requests, sends them, normalizes the responses, and gives you an in-memory cache. This post uses the official Next.js integration package (@apollo/client-integration-nextjs). It exposes a registerApolloClient(...) helper that creates a fresh Apollo client on every server request, so two users hitting the app at the same time never share a cache, and the client code never gets sent to the browser. Step 2 wires this up.
This post combines the two: every page is a Server Component that calls query(...). Every mutation runs from a Server Action that calls getClient().mutate(...). No GraphQL code runs in the browser. No useQuery hooks. No separate loading states to manage on the client.
Writing a note-taking UI from scratch is a detour from the point of this series. The JSX for a note card, the Tailwind palette for tag badges, the debounced search input, the Markdown renderer: none of that is specifically about Strapi or GraphQL. To keep the focus on the integration, this post starts from a starter template on GitHub. The starter already has the UI and routing done, and the post walks through adding the GraphQL layer on top.
What the starter contains:
NoteCard, NoteActions, NotesSearch, TagBadge, Markdown, Nav, plus the layout.tsx.lib/placeholder.ts.lib/apollo-client.ts holds a commented-out skeleton; lib/graphql.ts exports nothing; every Server Action console.logs its input.Once you run through this post, every stub is replaced by a real query or mutation. The end state matches the frontend/ directory in the repository root.
Clone the starter template repository linked above, then move into the starter-template/ directory and install dependencies:
git clone https://github.com/PaulBratslavsky/strapi-nextjs-grapql-starter-for-post.git client
cd client
# .env.local.example sets STRAPI_GRAPHQL_URL=http://localhost:1337/graphql
cp .env.local.example .env.local
npm install
npm run devOpen http://localhost:3000. You should see three placeholder notes, a nav bar with Notes / Search / Stats / New links, and a description on the home page saying placeholder data is being rendered. Clicking through /notes/[documentId], /search, /stats, /tags/<slug>, and /notes/new all work; they just show placeholder content.
Always load the dev server at
http://localhost:3000, not the LAN IP. Server Actions in Next.js are origin-locked by default, and the starter only allow-listslocalhost:3000. If you load the app at the LAN IP (or a tunnel URL, or a Codespaces preview), the buttons that call Server Actions later in this post (Pin, Archive, Edit save) will silently no-op: the click fires but no network request goes out, and there is no error in the console. The starter'snext.config.tshas a commentedallowedOriginsblock at the bottom showing where to add additional origins if you do need LAN access for testing.
Once the Part 2 backend is running on http://localhost:1337/graphql, populate it with demo tags and notes so the queries you wire up later in this post actually return something. The starter ships a script that does this for you over GraphQL:
npm run seedThis creates five tags (ideas, work, personal, bugs, drafts), nine notes spread across them with a mix of pinned/active/archived states, and is idempotent: re-running it skips entries that already exist (matched by tag slug or note title). The script uses createTag, createNote, and archiveNote against STRAPI_GRAPHQL_URL (defaulting to http://localhost:1337/graphql), so anything that fails surfaces immediately as a GraphQL error.
While you are here, the starter also ships the Part 2 backend test script as scripts/test-graphql.mjs, exposed as npm run test:backend. It is a copy of the script in the backend repo (the comment at the top of the file says so). Run it before you start wiring up queries to confirm the soft-delete and page-cap rules from Part 2 still pass:
npm run test:backendYou should see all green. If anything fails, recheck Part 2 Step 6.
Take two minutes to explore the file layout. The pieces that matter for the rest of this post:
1starter-template/
2βββ app/ # all routes, today importing from lib/placeholder.ts
3βββ components/ # all UI components (done, not touched again)
4βββ scripts/
5β βββ seed.mjs # populates Strapi with demo tags + notes
6β βββ test-graphql.mjs # backend contract test (copy of the one in graphql-server/)
7βββ lib/
8 βββ apollo-client.ts # stub with commented-out skeleton; Step 2 fills in
9 βββ graphql.ts # empty; each step appends one gql document
10 βββ placeholder.ts # hardcoded data powering every page until you wire queries
11 βββ auth.ts # Part 4 parking spot; ignore for nowNow stop the dev server and start filling in the GraphQL.
Open lib/apollo-client.ts. It currently contains a long comment block showing the target shape. Replace its contents with:
1// lib/apollo-client.ts
2import { HttpLink } from "@apollo/client";
3import {
4 registerApolloClient,
5 ApolloClient,
6 InMemoryCache,
7} from "@apollo/client-integration-nextjs";
8
9const STRAPI_GRAPHQL_URL =
10 process.env.STRAPI_GRAPHQL_URL ?? "http://localhost:1337/graphql";
11
12export const { getClient, query, PreloadQuery } = registerApolloClient(() => {
13 return new ApolloClient({
14 cache: new InMemoryCache({
15 typePolicies: {
16 Note: { keyFields: ["documentId"] },
17 Tag: { keyFields: ["documentId"] },
18 },
19 }),
20 link: new HttpLink({
21 uri: STRAPI_GRAPHQL_URL,
22 fetchOptions: { cache: "no-store" },
23 }),
24 });
25});Two details worth understanding:
typePolicies keyed by documentId. Apollo's cache uses id to identify entries by default. Strapi v5 uses documentId instead. The numeric id can change across operations, so caching by it would mismatch the same row across two queries. Every content type you read in this app needs an entry in typePolicies with keyFields: ["documentId"]. When Part 4 adds authentication and you start reading User, that type needs an entry here too.
fetchOptions: { cache: "no-store" }. This turns off Next.js's fetch cache for GraphQL requests. A dashboard-style UI like this one needs to show the current state of the data after every mutation, so caching the response would show stale rows. For queries that genuinely are cacheable, pass context: { fetchOptions: { cache: "force-cache" } } on the individual query(...) call instead.
registerApolloClient exports three things:
query({ query, variables }): shorthand for getClient().query(...). Use this in Server Components.getClient(): the raw client. Use this in Server Actions when you call .mutate().PreloadQuery: a helper for streaming results from server to client. Not used in this post.Nothing renders differently yet, since no page imports apollo-client yet. That changes in Step 3.
Before lib/graphql.ts starts filling up, here are five things you will see in every document the post adds. If you are coming from Part 2's Apollo Sandbox sessions, these will look familiar.
The gql tag. Every GraphQL document lives inside a template literal tagged with gql (imported from @apollo/client):
1import { gql } from "@apollo/client";
2
3export const MY_QUERY = gql`
4 query MyQuery { ... }
5`;The gql tag parses the template string into a document AST at build time and validates the syntax. Exporting the result as a named const lets you reuse it across pages.
Queries vs. mutations. Both are declared the same way; only the keyword differs.
1// Query: a read. Returns data. Safe, idempotent, cacheable.
2query ActiveNotes {
3 notes { title }
4}
5
6// Mutation: a write. Modifies server state. Returns the affected rows.
7mutation CreateNote($data: NoteInput!) {
8 createNote(data: $data) { documentId }
9}In this tutorial, queries run through query({ query, variables }) from @/lib/apollo-client inside Server Components. Mutations run through getClient().mutate({ mutation, variables }) inside Server Actions.
Variables. Inputs to a query or mutation. Declared in the operation header with $name: Type, referenced inside the body with $name, and passed at call time via the variables key:
1// The document.
2export const NOTE_DETAIL = gql`
3 query Note($documentId: ID!) {
4 note(documentId: $documentId) { title }
5 }
6`;
7
8// The call site (in a Server Component).
9await query({
10 query: NOTE_DETAIL,
11 variables: { documentId: "abc123" },
12});ID! means the value is required (non-null). Variable types come straight from the schema: ID, String, Int, Boolean, plus the input types Strapi generated for each content type.
Fragments. Reusable selection sets. Define a fragment once, compose it into any query that returns the same type:
1export const NOTE_FIELDS = gql`
2 fragment NoteFields on Note {
3 documentId
4 title
5 pinned
6 tags { name slug color }
7 }
8`;
9
10export const ACTIVE_NOTES = gql`
11 ${NOTE_FIELDS}
12 query ActiveNotes {
13 notes { ...NoteFields }
14 }
15`;
16
17export const NOTE_DETAIL = gql`
18 ${NOTE_FIELDS}
19 query Note($documentId: ID!) {
20 note(documentId: $documentId) {
21 ...NoteFields
22 content
23 }
24 }
25`;The ${NOTE_FIELDS} interpolation injects the fragment definition into the document so Apollo knows what ...NoteFields means. Any query that extends a fragment can also add extra fields on top (NOTE_DETAIL adds content here).
Apollo's cache normalizes entities by documentId (see Step 2's typePolicies), so a note fetched by a list query is available to a later detail query's render as long as the field sets overlap.
Strapi's Shadow CRUD input types. When Part 2 added the Note content type, Strapi auto-generated a set of input types for it:
NoteInput: the type for the data argument on createNote and updateNote. It has one field per attribute on Note (title, content, pinned, archived, plus tags as an array of related documentIds).NoteFiltersInput: the type for the filters argument on notes(...). It has one entry per scalar, each accepting operators (eq, ne, containsi, in, and so on), plus and / or / not for composing filters.You do not declare these in lib/graphql.ts. You reference them by name in operation variable headers ($data: NoteInput!, $filters: NoteFiltersInput). Part 2 Step 5 introduced the Shadow CRUD terminology; this is what those auto-generated types look like when you call them from the client.
Every snippet you add to lib/graphql.ts for the rest of this post uses these five patterns.
Open lib/graphql.ts (currently export {} only). Replace its contents with:
1// lib/graphql.ts
2import { gql } from "@apollo/client";
3
4// Fields shown on a note card or list row. `content` is NOT here because it
5// is only fetched on the detail page.
6export const NOTE_FIELDS = gql`
7 fragment NoteFields on Note {
8 documentId
9 title
10 pinned
11 archived
12 updatedAt
13 wordCount
14 readingTime
15 excerpt(length: 180)
16 tags {
17 documentId
18 name
19 slug
20 color
21 }
22 }
23`;
24
25export const ACTIVE_NOTES = gql`
26 ${NOTE_FIELDS}
27 query ActiveNotes {
28 notes(sort: ["pinned:desc", "updatedAt:desc"]) {
29 ...NoteFields
30 }
31 }
32`;Two notes on the fragment:
NOTE_FIELDS is reused by every list query you add later (SEARCH_NOTES, NOTES_BY_TAG). Defining it once keeps the selection set consistent and lets Apollo's cache share rows across queries.excerpt(length: 180) calls the computed field from Part 2 Step 7 with a GraphQL argument. Every other computed field (wordCount, readingTime) is a plain selection.Open app/page.tsx. Today it imports PLACEHOLDER_NOTES and renders it. Replace the file's contents with:
1// app/page.tsx
2import { query } from "@/lib/apollo-client";
3import { ACTIVE_NOTES } from "@/lib/graphql";
4import { NoteCard } from "@/components/note-card";
5
6type Note = Parameters<typeof NoteCard>[0]["note"];
7
8export const dynamic = "force-dynamic";
9
10export default async function Home() {
11 const { data } = await query<{ notes: Note[] }>({ query: ACTIVE_NOTES });
12 const notes = data?.notes ?? [];
13
14 return (
15 <div className="space-y-6">
16 <header className="space-y-1">
17 <h1 className="text-2xl font-semibold">Your notes</h1>
18 <p className="text-sm text-neutral-500">
19 {notes.length} active, sorted by pinned then recency. Powered by the{" "}
20 <code className="rounded bg-neutral-100 px-1 py-0.5 font-mono text-xs">
21 notes
22 </code>{" "}
23 Shadow CRUD query.
24 </p>
25 </header>
26
27 {notes.length === 0 ? (
28 <p className="text-sm text-neutral-500">No notes yet.</p>
29 ) : (
30 <div className="grid gap-4 md:grid-cols-2">
31 {notes.map((n) => (
32 <NoteCard key={n.documentId} note={n} />
33 ))}
34 </div>
35 )}
36 </div>
37 );
38}Make sure the Strapi server is running at
http://localhost:1337/graphqlbefore reloading. Without it the Server Component will throw a fetch error and the page will fail to render. Start it from the Part 2 backend directory withnpm run develop.
Restart npm run dev (or let Next hot-reload). The home page now shows the notes you seeded in Part 2. The response came from the notes Shadow CRUD resolver, flowed through the RSC Apollo client, and rendered as HTML. No Apollo code runs in the browser.
/.app/page.tsx on the server.query({ query: ACTIVE_NOTES }) serialized the document and POSTed it to http://localhost:1337/graphql.notes Shadow CRUD resolver.archived: { eq: false } server-side, so the frontend does not pass it), each populated with the fields the fragment selected (including the three computed fields).page.tsx rendered the returned array as HTML.That is the read pattern you will use for every other list page in this post.
Add this to the bottom of lib/graphql.ts:
1export const NOTE_DETAIL = gql`
2 ${NOTE_FIELDS}
3 query Note($documentId: ID!) {
4 note(documentId: $documentId) {
5 ...NoteFields
6 content
7 }
8 }
9`;NOTE_DETAIL extends NOTE_FIELDS with content, which is Markdown. react-markdown renders it in the <Markdown> component already present in the starter.
Open app/notes/[documentId]/page.tsx. Replace its contents with:
1// app/notes/[documentId]/page.tsx
2import Link from "next/link";
3import { notFound } from "next/navigation";
4import { query } from "@/lib/apollo-client";
5import { NOTE_DETAIL } from "@/lib/graphql";
6import { Markdown } from "@/components/markdown";
7import { TagBadge } from "@/components/tag-badge";
8import { NoteActions } from "@/components/note-actions";
9
10type NoteDetail = {
11 documentId: string;
12 title: string;
13 pinned: boolean;
14 archived: boolean;
15 wordCount: number;
16 readingTime: number;
17 updatedAt: string;
18 content: string | null;
19 tags: Array<{
20 documentId: string;
21 name: string;
22 slug: string;
23 color?: string | null;
24 }>;
25};
26
27export const dynamic = "force-dynamic";
28
29export default async function NoteDetailPage({
30 params,
31}: {
32 params: Promise<{ documentId: string }>;
33}) {
34 const { documentId } = await params;
35
36 const { data } = await query<{ note: NoteDetail | null }>({
37 query: NOTE_DETAIL,
38 variables: { documentId },
39 });
40
41 const note = data?.note;
42 if (!note) notFound();
43
44 return (
45 <article className="space-y-6">
46 <Link href="/" className="text-sm text-neutral-500 hover:text-black">
47 β Back to notes
48 </Link>
49
50 <header className="flex items-start justify-between gap-4">
51 <div className="space-y-2">
52 <h1 className="flex items-center gap-2 text-3xl font-semibold">
53 {note.pinned && <span aria-label="pinned">π</span>}
54 {note.title}
55 </h1>
56 <p className="text-sm text-neutral-500">
57 {note.wordCount} words Β· ~{note.readingTime} min read Β· updated{" "}
58 {new Date(note.updatedAt).toLocaleDateString()}
59 </p>
60 {note.tags.length > 0 && (
61 <div className="flex flex-wrap gap-1.5">
62 {note.tags.map((t) => (
63 <TagBadge key={t.documentId} tag={t} />
64 ))}
65 </div>
66 )}
67 </div>
68 <NoteActions documentId={note.documentId} pinned={note.pinned} />
69 </header>
70
71 <Markdown>{note.content}</Markdown>
72 </article>
73 );
74}Key points:
params is a Promise in Next.js 16. Await it before using.notFound() sends the user to the 404 page when the documentId does not exist. The note query returns null for missing records, and Next.js turns that into its standard 404 page.<NoteActions> buttons still log to the console because Step 5 has not happened yet. That is fine.Click any note card from the home page. The detail view shows the real Markdown content, the computed fields, the tags, and the formatted updated-at date.
Reads were Server Components calling query(...). Writes are Server Actions calling getClient().mutate(...). The mutations in this post fall into two flows:
createNote, updateNote).togglePin, archiveNote).Add this block to the bottom of lib/graphql.ts:
1export const TAGS = gql`
2 query Tags {
3 tags(sort: ["name:asc"]) {
4 documentId
5 name
6 slug
7 color
8 }
9 }
10`;
11
12export const CREATE_NOTE = gql`
13 mutation CreateNote($data: NoteInput!) {
14 createNote(data: $data) {
15 documentId
16 }
17 }
18`;
19
20export const UPDATE_NOTE = gql`
21 mutation UpdateNote($documentId: ID!, $data: NoteInput!) {
22 updateNote(documentId: $documentId, data: $data) {
23 documentId
24 }
25 }
26`;
27
28export const TOGGLE_PIN = gql`
29 mutation TogglePin($documentId: ID!) {
30 togglePin(documentId: $documentId) {
31 documentId
32 pinned
33 }
34 }
35`;
36
37export const ARCHIVE_NOTE = gql`
38 mutation ArchiveNote($documentId: ID!) {
39 archiveNote(documentId: $documentId) {
40 documentId
41 archived
42 }
43 }
44`;TAGS is not a mutation but you need it for the create and edit forms (to render the tag checkboxes). The other four match Part 2's Shadow CRUD and custom mutations.
The starter already has app/notes/new/page.tsx rendering the form against PLACEHOLDER_TAGS. Replace the entire file with the GraphQL-wired version:
1// app/notes/new/page.tsx
2import Link from "next/link";
3import { query } from "@/lib/apollo-client";
4import { TAGS } from "@/lib/graphql";
5import { createNoteAction } from "./actions";
6
7type Tag = {
8 documentId: string;
9 name: string;
10 slug: string;
11 color?: string | null;
12};
13
14export const dynamic = "force-dynamic";
15
16export default async function NewNotePage() {
17 const { data } = await query<{ tags: Tag[] }>({ query: TAGS });
18 const tags = data?.tags ?? [];
19
20 return (
21 <div className="max-w-2xl space-y-6">
22 <header className="space-y-1">
23 <Link href="/" className="text-sm text-neutral-500 hover:text-black">
24 β Back to notes
25 </Link>
26 <h1 className="text-2xl font-semibold">New note</h1>
27 <p className="text-sm text-neutral-500">
28 Submits the{" "}
29 <code className="rounded bg-neutral-100 px-1 py-0.5 font-mono text-xs">
30 createNote
31 </code>{" "}
32 Shadow CRUD mutation. Content is Markdown.
33 </p>
34 </header>
35
36 <form action={createNoteAction} className="space-y-5">
37 <div className="space-y-1">
38 <label htmlFor="title" className="block text-sm font-medium">
39 Title
40 </label>
41 <input
42 id="title"
43 name="title"
44 type="text"
45 required
46 className="w-full rounded border px-3 py-2 text-sm"
47 placeholder="Untitled note"
48 />
49 </div>
50
51 <div className="space-y-1">
52 <label htmlFor="content" className="block text-sm font-medium">
53 Content (Markdown)
54 </label>
55 <textarea
56 id="content"
57 name="content"
58 rows={10}
59 className="w-full rounded border px-3 py-2 font-mono text-sm"
60 placeholder="# Heading A paragraph. - list item"
61 />
62 </div>
63
64 {tags.length > 0 && (
65 <fieldset className="space-y-2">
66 <legend className="text-sm font-medium">Tags</legend>
67 <div className="flex flex-wrap gap-2">
68 {tags.map((t) => (
69 <label
70 key={t.documentId}
71 className="inline-flex cursor-pointer items-center gap-2 rounded border px-3 py-1.5 text-sm hover:bg-neutral-50 has-[:checked]:border-black has-[:checked]:bg-neutral-100"
72 >
73 <input
74 type="checkbox"
75 name="tagIds"
76 value={t.documentId}
77 className="sr-only"
78 />
79 {t.name}
80 </label>
81 ))}
82 </div>
83 </fieldset>
84 )}
85
86 <div className="flex items-center gap-3 pt-2">
87 <button
88 type="submit"
89 className="rounded bg-black px-4 py-2 text-sm font-medium text-white hover:bg-neutral-800"
90 >
91 Create note
92 </button>
93 <Link
94 href="/"
95 className="text-sm text-neutral-500 hover:text-black"
96 >
97 Cancel
98 </Link>
99 </div>
100 </form>
101 </div>
102 );
103}The only change from the starter is the top of the function: two imports and one query(TAGS) call replace the PLACEHOLDER_TAGS import. The form JSX is identical; it renders whatever tags array you pass it.
Replace app/notes/new/actions.ts with the real mutation:
1// app/notes/new/actions.ts
2"use server";
3
4import { revalidatePath } from "next/cache";
5import { redirect } from "next/navigation";
6import { getClient } from "@/lib/apollo-client";
7import { CREATE_NOTE } from "@/lib/graphql";
8
9// `formData.get()` returns `string | File | null`. Narrow before using.
10const asString = (v: FormDataEntryValue | null) =>
11 typeof v === "string" ? v : "";
12
13export async function createNoteAction(formData: FormData) {
14 const title = asString(formData.get("title")).trim();
15 const content = asString(formData.get("content"));
16 const tagIds = formData
17 .getAll("tagIds")
18 .filter((v): v is string => typeof v === "string");
19
20 if (!title) return;
21
22 const { data } = await getClient().mutate<{
23 createNote: { documentId: string };
24 }>({
25 mutation: CREATE_NOTE,
26 variables: {
27 data: {
28 title,
29 content,
30 pinned: false,
31 archived: false,
32 tags: tagIds,
33 },
34 },
35 });
36
37 revalidatePath("/");
38 const newId = data?.createNote?.documentId;
39 if (newId) redirect(`/notes/${newId}`);
40}Three things to notice:
getClient().mutate(...) is how Apollo runs a mutation. Inside a Server Action, getClient() from @/lib/apollo-client returns the same per-request client that query(...) uses for reads.content is a plain string because Note.content is richtext (Markdown). No block conversion is needed; the textarea value goes straight to Strapi.revalidatePath("/") clears the home page's cached server render so the new note appears immediately after the redirect.Click "New" in the nav, fill in the form, submit.
The Server Action runs, Strapi creates the note, and you are redirected to its detail page.
The edit page needs the real note data (to prefill the form) and the real tag list. Replace the entire contents of app/notes/[documentId]/edit/page.tsx with:
1// app/notes/[documentId]/edit/page.tsx
2import Link from "next/link";
3import { notFound } from "next/navigation";
4import { query } from "@/lib/apollo-client";
5import { NOTE_DETAIL, TAGS } from "@/lib/graphql";
6import { updateNoteAction } from "./actions";
7
8type Tag = {
9 documentId: string;
10 name: string;
11 slug: string;
12 color?: string | null;
13};
14
15type NoteDetail = {
16 documentId: string;
17 title: string;
18 content: string | null;
19 tags: Tag[];
20};
21
22export const dynamic = "force-dynamic";
23
24export default async function EditNotePage({
25 params,
26}: {
27 params: Promise<{ documentId: string }>;
28}) {
29 const { documentId } = await params;
30
31 const [noteRes, tagsRes] = await Promise.all([
32 query<{ note: NoteDetail | null }>({
33 query: NOTE_DETAIL,
34 variables: { documentId },
35 }),
36 query<{ tags: Tag[] }>({ query: TAGS }),
37 ]);
38
39 const note = noteRes.data?.note;
40 if (!note) notFound();
41
42 const allTags = tagsRes.data?.tags ?? [];
43 const selectedTagIds = new Set(note.tags.map((t) => t.documentId));
44 const boundAction = updateNoteAction.bind(null, documentId);
45
46 return (
47 <div className="max-w-2xl space-y-6">
48 <header className="space-y-1">
49 <Link
50 href={`/notes/${documentId}`}
51 className="text-sm text-neutral-500 hover:text-black"
52 >
53 β Back to note
54 </Link>
55 <h1 className="text-2xl font-semibold">Edit note</h1>
56 <p className="text-sm text-neutral-500">
57 Submits the{" "}
58 <code className="rounded bg-neutral-100 px-1 py-0.5 font-mono text-xs">
59 updateNote
60 </code>{" "}
61 Shadow CRUD mutation.
62 </p>
63 </header>
64
65 <form action={boundAction} className="space-y-5">
66 <div className="space-y-1">
67 <label htmlFor="title" className="block text-sm font-medium">
68 Title
69 </label>
70 <input
71 id="title"
72 name="title"
73 type="text"
74 required
75 defaultValue={note.title}
76 className="w-full rounded border px-3 py-2 text-sm"
77 />
78 </div>
79
80 <div className="space-y-1">
81 <label htmlFor="content" className="block text-sm font-medium">
82 Content (Markdown)
83 </label>
84 <textarea
85 id="content"
86 name="content"
87 rows={12}
88 defaultValue={note.content ?? ""}
89 className="w-full rounded border px-3 py-2 font-mono text-sm"
90 />
91 </div>
92
93 {allTags.length > 0 && (
94 <fieldset className="space-y-2">
95 <legend className="text-sm font-medium">Tags</legend>
96 <div className="flex flex-wrap gap-2">
97 {allTags.map((t) => (
98 <label
99 key={t.documentId}
100 className="inline-flex cursor-pointer items-center gap-2 rounded border px-3 py-1.5 text-sm hover:bg-neutral-50 has-[:checked]:border-black has-[:checked]:bg-neutral-100"
101 >
102 <input
103 type="checkbox"
104 name="tagIds"
105 value={t.documentId}
106 defaultChecked={selectedTagIds.has(t.documentId)}
107 className="sr-only"
108 />
109 {t.name}
110 </label>
111 ))}
112 </div>
113 </fieldset>
114 )}
115
116 <div className="flex items-center gap-3 pt-2">
117 <button
118 type="submit"
119 className="rounded bg-black px-4 py-2 text-sm font-medium text-white hover:bg-neutral-800"
120 >
121 Save changes
122 </button>
123 <Link
124 href={`/notes/${documentId}`}
125 className="text-sm text-neutral-500 hover:text-black"
126 >
127 Cancel
128 </Link>
129 </div>
130 </form>
131 </div>
132 );
133}Two points worth calling out:
Promise.all runs the two queries in parallel. The note and the tags do not depend on each other..bind(null, documentId) is how a Server Action receives arguments beyond the FormData. The browser only posts the form fields; the documentId is captured in the server-side closure.Replace app/notes/[documentId]/edit/actions.ts:
1// app/notes/[documentId]/edit/actions.ts
2"use server";
3
4import { revalidatePath } from "next/cache";
5import { redirect } from "next/navigation";
6import { getClient } from "@/lib/apollo-client";
7import { UPDATE_NOTE } from "@/lib/graphql";
8
9const asString = (v: FormDataEntryValue | null) =>
10 typeof v === "string" ? v : "";
11
12export async function updateNoteAction(
13 documentId: string,
14 formData: FormData,
15) {
16 const title = asString(formData.get("title")).trim();
17 const content = asString(formData.get("content"));
18 const tagIds = formData
19 .getAll("tagIds")
20 .filter((v): v is string => typeof v === "string");
21
22 if (!title) return;
23
24 await getClient().mutate({
25 mutation: UPDATE_NOTE,
26 variables: {
27 documentId,
28 data: { title, content, tags: tagIds },
29 },
30 });
31
32 revalidatePath("/");
33 revalidatePath(`/notes/${documentId}`);
34 redirect(`/notes/${documentId}`);
35}Two properties of updateNote worth knowing:
NoteInput is the same type createNote uses. Every attribute is effectively optional on update; anything not included in data is left unchanged on the server.tags: [...] replaces the entire relation. To incrementally add or remove individual tags, Strapi exposes tags: { connect: [...], disconnect: [...] } instead. Full replacement is fine for this tutorial.Click "Edit" from any note detail page. The form prefills with the current title, content, and tag selections.
Save. You are redirected back to the detail view with the updated values.
The note detail page (app/notes/[documentId]/page.tsx) already includes a <NoteActions> component in its header. That component renders three buttons: Edit, Pin / Unpin, and Archive. The Edit button is a link to the edit page you just wired up. The Pin and Archive buttons call Server Actions that live alongside the detail page at app/notes/[documentId]/actions.ts.
Right now those actions only console.log their input (the starter's placeholder stub). This sub-step replaces them with the two custom mutations from Part 2 Step 10: togglePin (flips the pinned boolean on the note) and archiveNote (sets archived: true, which is the soft-delete flag this app uses to hide notes from the list).
These are called "inline" actions because there is no form involved. The client component calls the Server Action directly from an onClick handler, wrapped in useTransition so the button can disable itself while the request is in flight. That client component already exists in the starter (components/note-actions.tsx) and imports both action names from app/notes/[documentId]/actions.ts. The only missing piece is giving those exported functions real bodies.
Replace the entire contents of app/notes/[documentId]/actions.ts with:
1// app/notes/[documentId]/actions.ts
2"use server";
3
4import { revalidatePath } from "next/cache";
5import { redirect } from "next/navigation";
6import { getClient } from "@/lib/apollo-client";
7import { TOGGLE_PIN, ARCHIVE_NOTE } from "@/lib/graphql";
8
9export async function togglePinAction(documentId: string) {
10 await getClient().mutate({
11 mutation: TOGGLE_PIN,
12 variables: { documentId },
13 });
14 revalidatePath(`/notes/${documentId}`);
15 revalidatePath("/");
16}
17
18export async function archiveNoteAction(documentId: string) {
19 await getClient().mutate({
20 mutation: ARCHIVE_NOTE,
21 variables: { documentId },
22 });
23 revalidatePath("/");
24 redirect("/");
25}The <NoteActions> client component (already in the starter) calls these via useTransition, so the buttons disable during the server round trip and re-enable once revalidatePath refreshes the page.
Open a note detail page. Click "Pin"; the π icon appears.
Click "Archive". You are redirected to the home page and the note is gone from the list.
This is the soft-delete pattern from Part 2 Step 10 in action. The entry is not actually deleted. It still exists in Strapi with archived: true on the record, and you can confirm this by opening the Content Manager in the Strapi admin UI.
The frontend never sees it because the soft-delete middlewares from Part 2 Step 6 do the work on the server: callers cannot filter on archived, and Query.notes only ever returns rows where archived: false. The frontend does not need to do anything; the backend enforces the rule on its own.
To confirm the contract from the client side, open the Apollo Sandbox at http://localhost:1337/graphql and try a query that explicitly asks for archived rows:
1query {
2 notes(filters: { archived: { eq: true } }) {
3 title
4 }
5}The response is rejected with FORBIDDEN and the message Cannot filter on \archived` directly. ...`. Drop the filter argument:
1query {
2 notes {
3 title
4 archived
5 }
6}
The response is a 200 OK with every entry showing archived: false. The archived note you just created is absent. The soft-delete middlewares are doing their job.
Add this to the bottom of lib/graphql.ts:
1export const SEARCH_NOTES = gql`
2 ${NOTE_FIELDS}
3 query SearchNotes($q: String!) {
4 searchNotes(query: $q) {
5 ...NoteFields
6 }
7 }
8`;Replace app/search/page.tsx:
1// app/search/page.tsx
2import { query } from "@/lib/apollo-client";
3import { SEARCH_NOTES } from "@/lib/graphql";
4import { NoteCard } from "@/components/note-card";
5import { NotesSearch } from "@/components/notes-search";
6
7type Note = Parameters<typeof NoteCard>[0]["note"];
8
9export const dynamic = "force-dynamic";
10
11export default async function SearchPage({
12 searchParams,
13}: {
14 searchParams: Promise<{ q?: string }>;
15}) {
16 const { q } = await searchParams;
17 const term = (q ?? "").trim();
18
19 let notes: Note[] = [];
20 if (term) {
21 const { data } = await query<{ searchNotes: Note[] }>({
22 query: SEARCH_NOTES,
23 variables: { q: term },
24 });
25 notes = data?.searchNotes ?? [];
26 }
27
28 return (
29 <div className="space-y-6">
30 <header className="space-y-1">
31 <h1 className="text-2xl font-semibold">Search</h1>
32 <p className="text-sm text-neutral-500">
33 Calls the{" "}
34 <code className="rounded bg-neutral-100 px-1 py-0.5 font-mono text-xs">
35 searchNotes
36 </code>{" "}
37 custom query. Archived notes are excluded.
38 </p>
39 </header>
40
41 <NotesSearch initialQuery={term} />
42
43 {term && (
44 <p className="text-sm text-neutral-500">
45 {notes.length} result{notes.length === 1 ? "" : "s"} for “{term}
46 ”.
47 </p>
48 )}
49
50 {notes.length > 0 && (
51 <div className="grid gap-4 md:grid-cols-2">
52 {notes.map((n) => (
53 <NoteCard key={n.documentId} note={n} />
54 ))}
55 </div>
56 )}
57 </div>
58 );
59}The <NotesSearch> client component (already in the starter) debounces input for 300 ms and pushes ?q=... into the URL via router.replace inside startTransition. When the URL changes, Next re-renders this page as an RSC with the new q search parameter. No client-side GraphQL code runs.
Type into the input. Each debounced commit fires a new GraphQL request to Strapi's searchNotes resolver.
Add this to the bottom of lib/graphql.ts:
1export const NOTES_BY_TAG = gql`
2 ${NOTE_FIELDS}
3 query NotesByTag($slug: String!) {
4 notesByTag(slug: $slug) {
5 ...NoteFields
6 }
7 }
8`;Replace app/tags/[slug]/page.tsx:
1// app/tags/[slug]/page.tsx
2import Link from "next/link";
3import { query } from "@/lib/apollo-client";
4import { NOTES_BY_TAG } from "@/lib/graphql";
5import { NoteCard } from "@/components/note-card";
6
7type Note = Parameters<typeof NoteCard>[0]["note"];
8
9export const dynamic = "force-dynamic";
10
11export default async function TagPage({
12 params,
13}: {
14 params: Promise<{ slug: string }>;
15}) {
16 const { slug } = await params;
17
18 const { data } = await query<{ notesByTag: Note[] }>({
19 query: NOTES_BY_TAG,
20 variables: { slug },
21 });
22 const notes = data?.notesByTag ?? [];
23
24 return (
25 <div className="space-y-6">
26 <header className="space-y-1">
27 <Link href="/" className="text-sm text-neutral-500 hover:text-black">
28 β Back to notes
29 </Link>
30 <h1 className="text-2xl font-semibold">
31 Notes tagged{" "}
32 <code className="rounded bg-neutral-100 px-2 py-0.5 font-mono text-lg">
33 {slug}
34 </code>
35 </h1>
36 <p className="text-sm text-neutral-500">
37 Calls the{" "}
38 <code className="rounded bg-neutral-100 px-1 py-0.5 font-mono text-xs">
39 notesByTag
40 </code>{" "}
41 custom query, which runs a nested relation filter on Tag.
42 </p>
43 </header>
44
45 {notes.length === 0 ? (
46 <p className="text-sm text-neutral-500">
47 No active notes tagged <code className="font-mono">{slug}</code>.
48 </p>
49 ) : (
50 <div className="grid gap-4 md:grid-cols-2">
51 {notes.map((n) => (
52 <NoteCard key={n.documentId} note={n} />
53 ))}
54 </div>
55 )}
56 </div>
57 );
58}Click a tag pill on any note card. The slug flows from the URL into the GraphQL variable, the custom resolver runs Part 2 Step 9.4's nested filter (tags: { slug: { $eq: slug } } under the hood), and the matching notes render.
noteStats is the showcase for Part 2's custom object types. It returns a NoteStats (three scalar counts) plus a list of TagCount objects. Neither is a content-type-derived type; both were declared via nexus.objectType in Part 2 Step 8.
Add this to the bottom of lib/graphql.ts (this is the last GraphQL document the tutorial adds):
1export const NOTE_STATS = gql`
2 query NoteStats {
3 noteStats {
4 total
5 pinned
6 archived
7 byTag {
8 slug
9 name
10 count
11 }
12 }
13 }
14`;Replace app/stats/page.tsx:
1// app/stats/page.tsx
2import Link from "next/link";
3import { query } from "@/lib/apollo-client";
4import { NOTE_STATS } from "@/lib/graphql";
5
6type Stats = {
7 total: number;
8 pinned: number;
9 archived: number;
10 byTag: Array<{ slug: string; name: string; count: number }>;
11};
12
13export const dynamic = "force-dynamic";
14
15export default async function StatsPage() {
16 const { data } = await query<{ noteStats: Stats }>({ query: NOTE_STATS });
17 const stats = data?.noteStats;
18 if (!stats) return null;
19
20 const counts = [
21 { label: "Total", value: stats.total },
22 { label: "Pinned", value: stats.pinned },
23 { label: "Archived", value: stats.archived },
24 ];
25
26 return (
27 <div className="space-y-8">
28 <header className="space-y-1">
29 <h1 className="text-2xl font-semibold">Stats</h1>
30 <p className="text-sm text-neutral-500">
31 Aggregated via the{" "}
32 <code className="rounded bg-neutral-100 px-1 py-0.5 font-mono text-xs">
33 noteStats
34 </code>{" "}
35 custom query, returning the{" "}
36 <code className="rounded bg-neutral-100 px-1 py-0.5 font-mono text-xs">
37 NoteStats
38 </code>{" "}
39 object type with a per-tag{" "}
40 <code className="rounded bg-neutral-100 px-1 py-0.5 font-mono text-xs">
41 TagCount
42 </code>{" "}
43 breakdown.
44 </p>
45 </header>
46
47 <section className="grid gap-4 md:grid-cols-3">
48 {counts.map((c) => (
49 <div key={c.label} className="rounded-lg border p-4">
50 <div className="text-xs uppercase tracking-wide text-neutral-500">
51 {c.label}
52 </div>
53 <div className="mt-1 text-3xl font-semibold">{c.value}</div>
54 </div>
55 ))}
56 </section>
57
58 <section className="space-y-3">
59 <h2 className="text-xs font-medium uppercase tracking-wider text-neutral-500">
60 By tag
61 </h2>
62 <ul className="divide-y rounded-lg border">
63 {stats.byTag.map((t) => (
64 <li
65 key={t.slug}
66 className="flex items-center justify-between px-4 py-3"
67 >
68 <Link
69 href={`/tags/${t.slug}`}
70 className="font-medium hover:underline"
71 >
72 {t.name}
73 </Link>
74 <span className="text-sm tabular-nums text-neutral-500">
75 {t.count}
76 </span>
77 </li>
78 ))}
79 </ul>
80 </section>
81 </div>
82 );
83}Three GraphQL type features show up on this one page:
NoteStats.total: Int!) renders as a plain number. The exclamation mark in the schema means the server is guaranteed to return a value, so the render does not need a null check.NoteStats.byTag: [TagCount!]!) is always an array. In practice Strapi may return an empty array; the render handles that case (it just shows an empty list).TagCount lives inside NoteStats) is selected by nesting the selection set inside the parent, the same way you write tags { name } on a Note.Every page now reads from Strapi. lib/placeholder.ts is no longer imported anywhere. Delete it:
rm lib/placeholder.tsRe-run npm run dev to confirm nothing broke. Every route should render live data from the backend.
The final starter-template/ layout now matches the reference frontend/ directory exactly:
1starter-template/
2βββ app/
3β βββ layout.tsx
4β βββ page.tsx # notes (Shadow CRUD list)
5β βββ search/page.tsx # searchNotes (custom query)
6β βββ stats/page.tsx # noteStats + TagCount (custom types)
7β βββ tags/[slug]/page.tsx # notesByTag (custom query)
8β βββ notes/
9β βββ new/{page,actions}.tsx # createNote
10β βββ [documentId]/
11β βββ page.tsx # note + inline actions
12β βββ actions.ts # togglePin, archiveNote
13β βββ edit/{page,actions}.tsx # updateNote
14βββ components/
15β βββ nav.tsx
16β βββ note-card.tsx
17β βββ note-actions.tsx
18β βββ notes-search.tsx
19β βββ tag-badge.tsx
20β βββ markdown.tsx
21βββ lib/
22 βββ apollo-client.ts # registerApolloClient + typePolicies
23 βββ graphql.ts # every gql document
24 βββ auth.ts # Part 4 parking spotNo Apollo Client instance is ever shipped to the browser. Every read is a Server Component. Every write is a Server Action. The only client-side JavaScript is the debounced search input and the pending-state spinner on the inline action buttons.
A Next.js 16 App Router frontend that exercises every public-facing GraphQL operation from Part 2:
notes, note, tags.createNote, updateNote.searchNotes, notesByTag, noteStats.togglePin, archiveNote.NoteStats, TagCount rendered on /stats.wordCount, readingTime, excerpt(length: Int) rendered on every note card and detail page.One page per operation, one GraphQL document per page, a shared NoteFields fragment for list-vs-detail reuse.
Part 4: users and per-user content. Adds login through Strapi's users-permissions plugin and a cookie-stored JWT flow on the Next.js side. Adds a per-user ownership model (an owner relation on Note) so each user only reads and writes their own notes. On the backend, two new files do the work: a middleware that filters reads to the caller's own notes, and a policy that blocks writes to other users' notes. The frontend gains /login, /register, a middleware.ts that protects routes, and an <AuthNav /> component. The auth stub in lib/auth.ts and the comment in lib/apollo-client.ts are both placeholders for Part 4.
Citations
revalidatePath: https://nextjs.org/docs/app/api-reference/functions/revalidatePath