Part 2 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, Shadow CRUD restrictions, custom queries, and custom mutations.New to Strapi or GraphQL? Start with Part 1. Already comfortable with Shadow CRUD and basic custom resolvers? You are in the right place.
TL;DR
src/extensions/graphql/ folder, same aggregator. Nothing gets thrown out; everything new is added alongside.Note and a Tag, joined many-to-many), then use it to walk through the rest of the GraphQL plugin's customization APIs.resolversConfig; turning parts of Shadow CRUD off (actions, output fields, filter inputs); adding new object types for aggregate responses with nexus.objectType; three custom queries that use the Document Service (and one raw-SQL example with strapi.db.connection.raw); three custom mutations.@strapi/plugin-graphql installed and a working aggregator under src/extensions/graphql/.Part 1 left you with a Strapi v5 project named server. It has the example blog model (Article, Author, Category), @strapi/plugin-graphql installed and configured in config/plugins.ts, and a small customization folder under src/extensions/graphql/ containing an aggregator, a computed-fields factory (Article.wordCount), and a custom-queries factory (Query.searchArticles).
Here is the folder you ended Part 1 with:
1server/
2├── config/
3│ └── plugins.ts # depthLimit, maxLimit, defaultLimit, landingPage, introspection
4└── src/
5 ├── index.ts # calls registerGraphQLExtensions
6 └── extensions/
7 └── graphql/
8 ├── index.ts # aggregator
9 ├── computed-fields.ts # Article.wordCount
10 └── queries.ts # Query.searchArticlesIn this post we will add:
src/extensions/graphql/: middlewares-and-policies.ts.src/policies/cap-page-size.ts.computed-fields.ts and queries.ts (alongside what Part 1 wrote, not in place of it).mutations.ts under src/extensions/graphql/.src/extensions/graphql/index.ts to register the three new factories.If you skipped Part 1, run through it first. The setup, plugin configuration, and aggregator scaffolding are not repeated here.
The note-taking model has two collection types:
name (text), slug (UID), color (enumeration with a fixed palette). No relations to define by hand; the inverse relation back to Note is generated for you.title (text), content (rich text, Markdown), pinned (boolean, default false), archived (boolean, default false), internalNotes (long text, marked private), plus a many-to-many relation to Tag.Then, in order:
internalNotes from the public schema and close off filter access to it.Query.notes and Query.note using middlewares, and cap the page size with a named policy.Note: wordCount, readingTime, and excerpt(length: Int).NoteStats and TagCount, for aggregate responses.searchNotes, noteStats, and notesByTag.togglePin, archiveNote, and duplicateNote.By the end, every customization API the GraphQL plugin exposes has been used at least once against a realistic model.
Start the dev server if it is not already running:
npm run developOpen the admin UI at http://localhost:1337/admin, then:
Tag. Leave API ID (singular) as tag and API ID (plural) as tags. Click the Advanced Settings tab in the same dialog and uncheck Draft & Publish. Click Continue.name, leave the type as Short text. Open the Advanced settings tab and check Required field. Click Add another field.slug, and set Attached field to name. Click Add another field.color, and add these values one per line: red, blue, green, yellow, purple, gray. Open the Advanced settings tab and set Default value to gray. Click Finish.Tags are pure labels, so Draft & Publish adds nothing here. Turning it off means a tag is live the moment you save it, and you do not have to pass status: 'published' in any tag-related query later.
Here is the final look at our tag collection:
Open src/api/tag/content-types/tag/schema.json to confirm the attributes look right:
1{
2 "kind": "collectionType",
3 "collectionName": "tags",
4 "info": {
5 "singularName": "tag",
6 "pluralName": "tags",
7 "displayName": "Tag"
8 },
9 "options": {
10 "draftAndPublish": false
11 },
12 "pluginOptions": {},
13 "attributes": {
14 "name": {
15 "type": "string",
16 "required": true
17 },
18 "slug": {
19 "type": "uid",
20 "targetField": "name"
21 },
22 "color": {
23 "type": "enumeration",
24 "default": "gray",
25 "enum": ["red", "blue", "green", "yellow", "purple", "gray"]
26 }
27 }
28}Back in the Content-Type Builder:
Note and click the Advanced Settings tab in the same dialog. Uncheck Draft & Publish. Click Continue. (Turning Draft & Publish off on Note means our custom resolvers do not need to pass status: 'published'. Article in Part 1 had it on, which is why searchArticles had to pass it.)title, Short text, Required.content. (Strapi has two rich-text variants: Blocks, an AST-style array, and Markdown, a plain string. Markdown is easier to render on the Next.js frontend in Part 3 and cheaper to handle on the backend, so we use it here.)pinned. Under Advanced settings, set Default value to false.archived. Under Advanced settings, set Default value to false.internalNotes, Long text. No required flag. Open the Advanced settings tab and check Private field. internalNotes is admin-only context (moderation notes, triage flags, anything the public API should never see). Marking it private keeps it out of every client-facing surface. On REST, Strapi strips private attributes from response bodies during sanitization. On GraphQL, the plugin goes further and removes private attributes from the output type, the filter input type, and the mutation input type. We verify this with an introspection query right after the schema snippet below.Note (you are editing it) and the right card is the target. Click the right-hand dropdown and pick Tag.tags, the inverse field on the Tag side should be named notes.Open src/api/note/content-types/note/schema.json to confirm the attributes look right:
1{
2 "kind": "collectionType",
3 "collectionName": "notes",
4 "info": {
5 "singularName": "note",
6 "pluralName": "notes",
7 "displayName": "Note"
8 },
9 "options": {
10 "draftAndPublish": false
11 },
12 "pluginOptions": {},
13 "attributes": {
14 "title": {
15 "type": "string",
16 "required": true
17 },
18 "content": {
19 "type": "richtext"
20 },
21 "pinned": {
22 "type": "boolean",
23 "default": false
24 },
25 "archived": {
26 "type": "boolean",
27 "default": false
28 },
29 "internalNotes": {
30 "type": "text",
31 "private": true
32 },
33 "tags": {
34 "type": "relation",
35 "relation": "manyToMany",
36 "target": "api::tag.tag",
37 "inversedBy": "notes"
38 }
39 }
40}private: true hid internalNotes from GraphQLOpen the Apollo Sandbox at http://localhost:1337/graphql and run:
1query PrivateReference {
2 note: __type(name: "Note") {
3 fields {
4 name
5 }
6 }
7 filter: __type(name: "NoteFiltersInput") {
8 inputFields {
9 name
10 }
11 }
12 input: __type(name: "NoteInput") {
13 inputFields {
14 name
15 }
16 }
17}Scan all three lists in the response. internalNotes is absent from every one. The GraphQL plugin reads the private: true flag out of schema.json and removes the attribute from the output type, the filter input type, and the mutation input type in one go. REST sanitization strips it from response bodies at the same time, so GET /api/notes never returns it either.
This is the Strapi-native way to hide sensitive fields from the public API. No extension code needed.
Same flow as Part 1, applied to the new content types.
find, findOne, create, and update. Leave delete unchecked. The frontend uses soft-delete via the archived flag, so the public API should never be able to hard-delete a note.find, findOne, create, update, and delete.The queries, policies, and aggregations later in this post need data to return. Create a few entries by hand so the Sandbox has something to work with.
Create three Tag entries through Content Manager, Tag, Create new entry. Suggested starter values:
| name | slug | color |
|---|---|---|
| Work | work | blue |
| Personal | personal | green |
| Ideas | ideas | yellow |
Strapi renders the color field as a dropdown with the six values from Step 1. The frontend in Part 3 maps each enum value to a Tailwind class, so you do not need to use every color in your seed data.
Click Save
Create three Note entries through Content Manager, Note, Create new entry. For each note:
Weekly review, Gift ideas, or Side-project backlog.wordCount returns a real count in Step 7. (readingTime uses Math.max(1, ...) so it is always at least 1, even on an empty note.)pinned on for one of the three; leave the others off.archived off for all of them. You can flip one to archived: true later when testing the archive rules.internalNotes with anything, for example moderator flag: low priority. The private: true flag from Step 2 keeps it out of every public GraphQL and REST response, so whatever you write here only shows up in the admin UI.Three notes are enough to test every resolver in the rest of the post. Add more if you like.
Shadow CRUD is how Strapi auto-generates your GraphQL schema at boot. At startup, the GraphQL plugin reads every registered content type and emits matching queries, mutations, input types, and filter types. Everything you used in Part 1, the full notes / note / createNote / updateNote surface, came out of Shadow CRUD.
The plugin does expose an extension API for turning parts of the generated schema off:
1strapi
2 .plugin("graphql")
3 .service("extension")
4 .shadowCRUD("api::note.note")
5 .disable() // remove the whole content type
6 .disableQueries() // remove find/findOne
7 .disableMutations() // remove create/update/delete
8 .disableAction("delete");
9
10strapi
11 .plugin("graphql")
12 .service("extension")
13 .shadowCRUD("api::note.note")
14 .field("internalNotes")
15 .disable() // remove the field entirely
16 .disableOutput() // remove from the Note output type
17 .disableInput() // remove from create/update inputs
18 .disableFilters(); // remove from NoteFiltersInputThe full vocabulary, for reference:
| Content-type level | Field level |
|---|---|
.disable() | .disable() |
.disableQueries() | .disableOutput() |
.disableMutations() | .disableInput() |
.disableAction('delete') | .disableFilters() |
.disableActions(['create','update']) |
Documented in full on the GraphQL plugin docs page.
Two simpler tools cover almost everything you would use Shadow CRUD customization for:
deleteNote, uncheck delete for the Public role in Step 3. The REST endpoint and the GraphQL resolver both return a Forbidden error. The mutation still appears in the schema, but no one can actually run it.private: true already hides sensitive fields. Step 2 put private: true on internalNotes. The GraphQL plugin removes the field from the Note output type, from NoteFiltersInput, and from NoteInput. No extension file needed.So when would you use Shadow CRUD customization? When you also want the field or action gone from the schema itself, so it does not show up in the Sandbox docs panel, in introspection responses, or in generated client types. Permissions block the call but leave the schema unchanged. Shadow CRUD customization changes the schema. For most projects the runtime block is enough, so this tutorial does not add a shadow-crud.ts file.
For real access control, use permissions (Step 3) and private: true (Step 2) first, then middlewares and policies (Step 6) for anything those two cannot express.
resolversConfig, middlewares and policiesresolversConfig is how you attach middlewares, policies, and auth rules to a resolver. The resolver can be one Shadow CRUD generated for you (like Query.notes or Mutation.createNote) or one you wrote yourself (like the searchNotes query in Step 9). resolversConfig is a plain object: keys are the resolver's full name (Query.notes, Mutation.createNote, Note.wordCount), values are configuration objects.
Both are functions that run around a resolver. They answer different questions.
Policies answer "should this request even proceed?" Per the Strapi docs, policies are "functions that execute specific logic on each request before it reaches the controller. They are mostly used for securing business logic." A policy returns true to let the request through or false to reject it. If the policy returns false, the resolver never runs. Policies are the natural home for authorization checks like "is the user logged in", "does this user own this row", or "is this request coming from an allowed IP".
Policies for the GraphQL plugin live in either src/policies/ (the global folder) or src/api/<api>/policies/ (the per-content-type folder). You refer to them by name in resolversConfig: global::<filename> for the first folder, api::<api>.<filename> for the second. The word "global" here means "lives in the global folder", not "applies to everything". A policy in src/policies/ is available everywhere by name, but you still have to attach it to each resolver in resolversConfig (or to each REST route in the route's config.policies) where you want it. Nothing applies a policy automatically.
Middlewares answer "what should happen before and after?" Per the Strapi docs, middlewares "alter the request or response flow at application or API levels." A middleware wraps the resolver call. It can run code before the resolver, call next(...) to let the resolver run, and run code after the resolver with the result in hand. Use a middleware for things like:
archived: { eq: false } to args.filters).Query.note middleware does this; it throws NotFoundError if the loaded note is archived).Do not use a middleware to reject a request for authorization reasons. That is what policies are for.
The GraphQL plugin exposes both through the same resolversConfig key (see the plugin docs). middlewares is an array; each entry can be either a function (defined inline) or a string (the name of a middleware you registered elsewhere). policies accepts the same two shapes.
They run in a fixed order: middlewares first (in the order you list them), then policies, then the resolver. Each middleware has a "before" half (the code before next(...)) and an "after" half (the code after next(...) returns). At request time:
next(...), the policies run in array order.false, the request is rejected and nothing further runs.The file below attaches four middlewares and one policy across two resolvers.
On Query.notes (the list query):
ForbiddenError if the caller tried to filter on archived (whether they asked for true or false). Only the server changes archived, so callers do not get to ask about it.archived: { eq: false } to args.filters. By the time this runs, we already know the caller did not send archived, so adding it cannot overwrite anything they sent.global::cap-page-size) that rejects any Query.notes call asking for more than 100 rows in one page. A simple yes/no check based on what the caller sent, which is exactly what policies are for.On Query.note (the single-fetch query):
NotFoundError if the note is archived. The list query is already covered by rules 1 and 2, but fetching a single note by documentId goes through a different code path. Without this middleware, anyone holding an archived note's documentId could still pull it down. With it, an archived note no longer exists from the public API's point of view, whether you ask for the list or fetch one by ID.Why use two middlewares for the soft-delete rule on Query.notes instead of one? Each one does a different job. The first looks at one field (args.filters.archived) and throws if it is present. The second sets that same field to { eq: false }, every time. You could put both checks in a single middleware, but splitting them keeps each one short, and it gives us three small middleware examples on the same resolver to compare against the timing middleware.
Why does Query.note check after the resolver runs instead of before? Because before the resolver runs, we do not yet know whether the requested note is archived. The Document Service has not loaded it. The simplest correct pattern is to call next(...) first, let the Document Service load the row, then check result.archived on the way back. This is the second basic middleware pattern: let the resolver run, then change or reject the response. The rejection middleware on Query.notes is the first pattern: look at args, reject before calling next(...). Both show up in the file below.
Create the file:
1// src/extensions/graphql/middlewares-and-policies.ts
2import type { GraphQLResolveInfo } from "graphql";
3import { errors } from "@strapi/utils";
4
5type NotesArgs = {
6 filters?: Record<string, unknown>;
7 pagination?: Record<string, unknown>;
8 sort?: string | string[];
9};
10
11type NoteArgs = {
12 documentId?: string;
13};
14
15type ResolverNext<A> = (
16 parent: unknown,
17 args: A,
18 context: unknown,
19 info: GraphQLResolveInfo,
20) => Promise<unknown>;
21
22export default function middlewaresAndPolicies() {
23 return {
24 resolversConfig: {
25 "Query.notes": {
26 middlewares: [
27 // Soft-delete invariant — rejection half.
28 // The `archived` field is server-controlled. Any caller-supplied
29 // filter on `archived` is rejected up front.
30 async (
31 next: ResolverNext<NotesArgs>,
32 parent: unknown,
33 args: NotesArgs,
34 context: unknown,
35 info: GraphQLResolveInfo,
36 ) => {
37 if (args?.filters?.archived !== undefined) {
38 throw new errors.ForbiddenError(
39 "Cannot filter on `archived` directly. Soft-deleted notes are not accessible via the public API.",
40 );
41 }
42 return next(parent, args, context, info);
43 },
44 // Soft-delete invariant — injection half.
45 // The first middleware guarantees `archived` was undefined here,
46 // so the injection is unconditional.
47 async (
48 next: ResolverNext<NotesArgs>,
49 parent: unknown,
50 args: NotesArgs,
51 context: unknown,
52 info: GraphQLResolveInfo,
53 ) => {
54 args.filters = {
55 ...(args?.filters ?? {}),
56 archived: { eq: false },
57 };
58 return next(parent, args, context, info);
59 },
60 // Timing logger.
61 // Wraps the rest of the chain to record how long Query.notes
62 // takes. Sees the final filter value because both soft-delete
63 // middlewares ran first.
64 async (
65 next: ResolverNext<NotesArgs>,
66 parent: unknown,
67 args: NotesArgs,
68 context: unknown,
69 info: GraphQLResolveInfo,
70 ) => {
71 const label = `[graphql] Query.notes (${JSON.stringify(args?.filters ?? {})})`;
72 console.time(label);
73 try {
74 return await next(parent, args, context, info);
75 } finally {
76 console.timeEnd(label);
77 }
78 },
79 ],
80 policies: ["global::cap-page-size"],
81 },
82 "Query.note": {
83 middlewares: [
84 // Soft-delete invariant — single-fetch coverage.
85 // Direct documentId lookup is a separate code path from
86 // Query.notes and needs its own enforcement. Let the resolver
87 // run so the entity is loaded, then inspect `archived` on the
88 // result and surface NotFoundError if it is true. From the
89 // public API's point of view, an archived note simply does not
90 // exist.
91 async (
92 next: ResolverNext<NoteArgs>,
93 parent: unknown,
94 args: NoteArgs,
95 context: unknown,
96 info: GraphQLResolveInfo,
97 ) => {
98 const result = (await next(parent, args, context, info)) as
99 | { archived?: boolean }
100 | null
101 | undefined;
102 if (result && result.archived === true) {
103 throw new errors.NotFoundError("Note not found.");
104 }
105 return result;
106 },
107 ],
108 },
109 },
110 };
111}Order matters on Query.notes. Middlewares run in the order they appear in the array, the policy runs after them, and the resolver runs last. So at request time:
archived in filters, it throws ForbiddenError and the chain stops here. The remaining middlewares, the policy, and the resolver are all skipped.args.filters.archived to { eq: false }. From this point on, every later step sees the same final filter.policyContext.args.pagination.pageSize and rejects if it is over 100.archived: false and a capped page size.Query.note works in reverse. Its single middleware lets the resolver run first, then checks the result on the way back. If the loaded entity has archived: true, the middleware throws NotFoundError. Otherwise it returns the entity untouched. This is the second basic middleware shape from earlier.
What these middlewares cover, and what they do not.
The middlewares above stop archived notes from coming back when someone calls the
notesquery (the list) or thenotequery (single fetch). Those are the two main ways the public API reads notes.If you want archived notes hidden everywhere, three more places need attention:
- Custom queries. The custom queries we add in Step 9 (
searchNotes,notesByTag) are separate resolvers. The middlewares above do not run on them. Both happen to be fine already:notesByTagfilters out archived notes inside its own resolver, andsearchNoteshas anincludeArchivedargument that defaults tofalse. Any new custom query you add later has to do its own archived check.- REST. Calls to
GET /api/notesorGET /api/notes/:documentIddo not go through the GraphQL plugin at all, so these middlewares never run. REST will return archived notes. The "GraphQL-only vs. both APIs" section right below shows how to make REST behave the same way.- Write mutations.
togglePin,updateNote, andduplicateNotewill currently let you change an archived note. That arguably contradicts the point of soft-delete: a "deleted" note should not be editable. Adding a guard is short: load the note, checkarchived, throw if true. We leave it as an exercise. Part 4 adds per-user ownership on top of these mutations and is the natural place to revisit them.
The two function signatures to remember:
A middleware is an async function written as async (next, parent, args, context, info) => .... The first argument, next, is a function: call it to let the next middleware run, or, if there are no more middlewares, to let the resolver run. Inside a middleware you can:
args before you call next(...). That changes what the resolver sees. The injection middleware does this; it adds archived: { eq: false } to args.filters before the resolver runs.next(...) finishes, with the response in hand. The Query.note middleware does this; it looks at the loaded note, and if note.archived is true, it throws NotFoundError instead of returning the note.next(...). The timing middleware does this; it records the start time before, then logs the duration after.If you throw an error instead of calling next(...), no further middleware runs, no policy runs, and the resolver does not run. The error goes back to the caller in the GraphQL response. That is how the rejection middleware works: it throws ForbiddenError and the request stops there.
A policy is a function written as (policyContext, config, { strapi }) => .... Return true or undefined to let the request through. Return false to reject it; Strapi turns that into a Policy Failed error. Policies run after every middleware, so by the time a policy runs, any middleware has already had a chance to change args.
When to pick which:
Policy Failed error is good enough.ForbiddenError on purpose, so the GraphQL response carries extensions.code: "FORBIDDEN". That code matches the meaning ("you are not allowed to ask about archived") better than Policy Failed would.Policies in resolversConfig can be either inline functions or strings that name a policy file (the plugin docs say both shapes work). The string form is global::<filename> for a file in src/policies/, or api::<api>.<filename> for one in src/api/<api>/policies/. We use the string form below so the same policy file can be referenced from both resolversConfig.policies (GraphQL) and a route's config.policies (REST). Part 4 takes advantage of that.
Before writing the policy, look at what its first argument is. Per the GraphQL plugin docs, when a policy runs from resolversConfig:
Policies directly implemented in resolversConfig are functions that take a context object and the strapi instance as arguments. The context object gives access to:
- the parent, args, context and info arguments of the GraphQL resolver,
- Koa's context with context.http and state with context.state.
So policyContext.args gives you the GraphQL resolver arguments (filters, pagination, sort, and whatever else the resolver accepts). policyContext.context.http gives you the underlying Koa request, in case you need to read headers. And policyContext.state.user gives you the signed-in user. (policyContext.context.state.user works too; both point at the same object.) The reason that matters: REST policies access the same user as policyContext.state.user. Using the short path lets the same policy file work in both REST and GraphQL, which Part 4 relies on. The PolicyContext type in the file below picks out only the fields this policy actually reads.
Create the policy file:
1// src/policies/cap-page-size.ts
2import type { Core } from "@strapi/strapi";
3
4const MAX_PAGE_SIZE = 100;
5
6type Pagination = {
7 pageSize?: number | string;
8 limit?: number | string;
9};
10
11type PolicyContext = {
12 args?: { pagination?: Pagination };
13};
14
15const capPageSize = (
16 policyContext: PolicyContext,
17 _config: unknown,
18 { strapi }: { strapi: Core.Strapi },
19): boolean => {
20 const pagination = policyContext?.args?.pagination ?? {};
21 const requested = Number(pagination.pageSize ?? pagination.limit ?? 0);
22
23 if (Number.isFinite(requested) && requested > MAX_PAGE_SIZE) {
24 strapi.log.warn(
25 `Query.notes blocked: pageSize ${requested} exceeds cap of ${MAX_PAGE_SIZE}.`,
26 );
27 return false;
28 }
29
30 return true;
31};
32
33export default capPageSize;The policy reads policyContext.args.pagination.pageSize (the GraphQL plugin also accepts limit as an alias for offset-style pagination, so we check both), coerces it to a number, and rejects if it is over the cap. Anything inside the cap, or any query without a pagination argument at all, returns true and the request proceeds.
Register the factory in the aggregator:
1// src/extensions/graphql/index.ts
2import type { Core } from "@strapi/strapi";
3import computedFields from "./computed-fields";
4import queries from "./queries";
5import middlewaresAndPolicies from "./middlewares-and-policies";
6
7export default function registerGraphQLExtensions(strapi: Core.Strapi) {
8 const extensionService = strapi.plugin("graphql").service("extension");
9
10 extensionService.use(middlewaresAndPolicies);
11 extensionService.use(computedFields);
12 extensionService.use(function extendQueries({ nexus }: any) {
13 return queries({ nexus, strapi });
14 });
15}Restart. From a terminal, check that all four rules are active.
First, the soft-delete middlewares. A bare query should succeed and exclude archived rows. A query that tries to filter on archived should be rejected with Forbidden, regardless of the value the caller passes:
# Bare query, no filter: succeeds, archived rows absent
curl -s -X POST http://localhost:1337/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"{ notes { title archived } }"}'
# -> {"data":{"notes":[{"title":"...","archived":false}, ...]}}
# Sneaky query, archived: true: rejected
curl -s -X POST http://localhost:1337/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"{ notes(filters:{ archived:{ eq: true } }){ title } }"}'
# -> {"errors":[{"message":"Cannot filter on `archived` directly. ... ", "extensions":{"code":"FORBIDDEN", ... }}],"data":null}
# Polite query, archived: false: also rejected. The server alone manages archived.
curl -s -X POST http://localhost:1337/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"{ notes(filters:{ archived:{ eq: false } }){ title } }"}'
# -> {"errors":[{"message":"Cannot filter on `archived` directly. ... ", "extensions":{"code":"FORBIDDEN", ... }}],"data":null}Next, the page-cap policy. Oversized requests should fail with Policy Failed. Requests inside the cap should succeed:
# pageSize over the cap: Policy Failed
curl -s -X POST http://localhost:1337/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"{ notes(pagination:{ pageSize: 500 }){ documentId } }"}'
# -> {"errors":[{"message":"Policy Failed", ... }],"data":null}
# pageSize inside the cap: 200 OK
curl -s -X POST http://localhost:1337/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"{ notes(pagination:{ pageSize: 10 }){ documentId } }"}'
# -> {"data":{"notes":[ ... ]}}Notice the two error shapes. The middleware throws ForbiddenError and produces extensions.code: "FORBIDDEN". The policy returns false and produces the standard Policy Failed message. Callers can branch on the code.
Finally, the single-fetch coverage on Query.note. Archive a note in the admin UI (or via the archiveNote mutation), grab its documentId, and run:
# Direct fetch of an archived note: rejected with NotFound
curl -s -X POST http://localhost:1337/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"query F($id: ID!){ note(documentId: $id){ documentId title archived } }","variables":{"id":"<archived-documentId>"}}'
# -> {"errors":[{"message":"Note not found.", "extensions":{"code":"STRAPI_NOT_FOUND_ERROR", ... }}],"data":{"note":null}}
# Direct fetch of an active note: 200 OK
curl -s -X POST http://localhost:1337/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"query F($id: ID!){ note(documentId: $id){ documentId title } }","variables":{"id":"<active-documentId>"}}'
# -> {"data":{"note":{"documentId":"...","title":"..."}}}In the Strapi process output, every successful list call should also log a line like [graphql] Query.notes ({"archived":{"eq":false}}): 12ms. The filter in the log always includes archived: { eq: false }, even for bare queries, because the injection middleware runs before the timing middleware. Rejected calls do not produce a timing log; the chain stops before the timing middleware runs.
Query.note does not log timings. If you want timing on the single-fetch path too, copy the timing middleware and drop it in front of the soft-delete middleware in Query.note's middlewares array.
resolversConfig only protects GraphQLThe middleware and the policy we just wrote sit in resolversConfig. That key is part of the GraphQL plugin's extension API, so the rules only run for /graphql requests. A caller hitting GET /api/notes skips both: the soft-delete middleware does not fire, and the page-size policy does not fire either.
You can see this from the terminal. GraphQL rejects the archived filter, REST returns archived rows without complaint:
# GraphQL: the middleware blocks the archived filter
curl -s -X POST http://localhost:1337/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"{ notes(filters:{ archived:{ eq: true } }){ title archived } }"}'
# -> {"errors":[{"message":"Cannot filter on `archived` directly. ... ",
# "extensions":{"code":"FORBIDDEN", ... }}],"data":null}
# REST: nothing blocks it, archived rows come back
curl -s "http://localhost:1337/api/notes?filters\[archived\]\[\$eq\]=true"
# -> {"data":[ ... archived notes here ... ],"meta":{ ... }}If your application only exposes a GraphQL endpoint, this is fine: the GraphQL rule is the only entry point, so the only door is locked. But if both surfaces are exposed (which is the default in Strapi v5), and the rule is genuinely "no caller of any API should ever see archived notes by default", you have to add the rule to REST as well.
The most direct way to mirror the rule onto REST is a route-level middleware on the Note router. Strapi's core router accepts a config block per action where you can attach Koa middlewares (see the Strapi route configuration docs). The middleware mirrors what the GraphQL pair does: reject if the caller asked for archived, then inject archived: false so the response only includes active notes.
1// src/api/note/routes/note.ts
2import { factories } from "@strapi/strapi";
3import { errors } from "@strapi/utils";
4
5const enforceSoftDelete = (ctx, next) => {
6 const filters = (ctx.query.filters ??= {}) as Record<string, unknown>;
7
8 // Rejection half: match the GraphQL behavior. If the caller tried to
9 // filter on `archived`, return a clear FORBIDDEN error instead of
10 // silently overwriting their filter.
11 if (filters.archived !== undefined) {
12 throw new errors.ForbiddenError(
13 "Cannot filter on `archived` directly. Soft-deleted notes are not accessible via the public API.",
14 );
15 }
16
17 // Injection half: every other request is forced to `archived: false`.
18 filters.archived = { $eq: false };
19 return next();
20};
21
22export default factories.createCoreRouter("api::note.note", {
23 config: {
24 find: { middlewares: [enforceSoftDelete] },
25 findOne: { middlewares: [enforceSoftDelete] },
26 },
27});REST find and findOne now behave the same way GraphQL does. A caller who sends ?filters[archived][$eq]=true gets a 403 Forbidden response with a clear message. A caller who sends nothing gets back only active notes. Two notes on the syntax: the querystring operator is $eq rather than eq because Strapi's REST filters use $-prefixed operators, and errors.ForbiddenError from @strapi/utils translates to a 403 response on REST (the same error class translates to extensions.code: "FORBIDDEN" on GraphQL).
Restart the dev server, then verify from a terminal. The same REST request that returned a 200 OK with archived rows earlier in this section should now return a 403:
# REST: caller-supplied archived filter, now rejected by the route middleware
curl -s -i "http://localhost:1337/api/notes?filters\[archived\]\[\$eq\]=true"
# -> HTTP/1.1 403 Forbidden
# -> {"data":null,"error":{"status":403,"name":"ForbiddenError",
# "message":"Cannot filter on `archived` directly. ...",
# "details":{}}}
# REST: bare query, succeeds and excludes archived notes
curl -s "http://localhost:1337/api/notes" | jq '.data | map(.archived)'
# -> [false, false, false, ...]The first request gets a 403 with the same message GraphQL produces. The second request comes back 200 OK with only active notes (the injection half added archived: { $eq: false } to the filter before the controller ran).
The cost of this approach: the soft-delete rule now lives in two files. The GraphQL version is in src/extensions/graphql/middlewares-and-policies.ts. The REST version is in src/api/note/routes/note.ts. If a future change updates one and not the other, the two surfaces drift apart.
There are two more places you could put a rule like this, each with its own trade-offs.
Global application middleware (config/middlewares.ts). A middleware registered here runs for every HTTP request before any router dispatches. For soft-delete this works, because the rule is "always inject archived: false", regardless of who the caller is. The catch is timing: a global middleware runs before Strapi populates ctx.state.user, so it cannot read the signed-in user. Any rule that depends on the user (like Part 4's ownership check) cannot live here.
Document Service middleware (src/index.ts register()). The Document Service is the layer below both REST and GraphQL. Every Note read, whether it arrives via GET /api/notes or { notes } in GraphQL, eventually calls strapi.documents("api::note.note").findMany(...) or findOne(...). A middleware registered there with strapi.documents.use(...) fires once and covers both APIs from a single file. The shape of such a middleware looks like:
1// src/index.ts (inside register, sketch only)
2strapi.documents.use(async (context, next) => {
3 if (context.uid !== "api::note.note") return next();
4 if (context.action !== "findMany" && context.action !== "findOne") return next();
5 // ... inspect or change context.params.filters here ...
6 return next();
7});The Strapi docs document this API on its own page (Document Service middlewares) but do not call it out as the cross-API solution; that framing is ours, derived from the architecture (Shadow CRUD GraphQL resolvers and REST controllers both go through the Document Service, source-confirmed in builders/resolvers/query.ts). We do not implement this version in Part 2; Part 4 uses the Document Service middleware approach for ownership scoping, where covering both APIs in one place becomes load-bearing instead of a nice-to-have.
Looking ahead to Part 4. Soft-delete is a simple rule that does not need the signed-in user, so the route-middleware approach above is enough for Part 2. Part 4's ownership rule is different: it must scope every read and write to
ctx.state.user. Repeating that rule inresolversConfigfor GraphQL and insrc/api/note/routes/note.tsfor REST means two files to keep in sync, plus extra code for any custom resolver. Part 4 instead implements the rule once as a Document Service middleware insrc/index.tsregister(), reads the user viastrapi.requestContext.get()?.state?.user, and gets both API surfaces covered from one file. Part 4 also keeps anis-note-ownerpolicy on the GraphQL write mutations as a worked policy example.
Part 1 introduced computed fields on Article with a single-line description field. Note's body lives in content, a markdown string. Word counting and excerpting run directly on that string; a small helper strips common markdown syntax so the results reflect rendered text, not the raw source.
Extend the existing computed-fields.ts so Article's wordCount from Part 1 stays as it is, and three new fields appear on Note:
1// src/extensions/graphql/computed-fields.ts
2const WORDS_PER_MINUTE = 200;
3const DEFAULT_EXCERPT_LENGTH = 180;
4
5type ArticleSource = { description?: string | null };
6type NoteSource = { content?: string | null };
7
8/** Remove common markdown syntax so counts and excerpts reflect rendered text. */
9function stripMarkdown(md: string): string {
10 return (md ?? "")
11 .replace(/```[\s\S]*?```/g, " ") // code fences
12 .replace(/`[^`]*`/g, " ") // inline code
13 .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1") // images -> alt
14 .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") // links -> text
15 .replace(/^#+\s+|^>\s+|^[-*+]\s+|^\d+\.\s+/gm, "") // headings, quotes, list markers
16 .replace(/\*\*([^*]*)\*\*|__([^_]*)__/g, (_, a, b) => a ?? b) // **bold** / __bold__
17 .replace(/\*([^*]*)\*|_([^_]*)_/g, (_, a, b) => a ?? b) // *italic* / _italic_
18 .replace(/\s+/g, " ")
19 .trim();
20}
21
22function countWords(text: string): number {
23 const trimmed = text.trim();
24 return trimmed ? trimmed.split(/\s+/).length : 0;
25}
26
27function truncateAt(text: string, maxLength: number): string {
28 return text.length <= maxLength
29 ? text
30 : text.slice(0, maxLength).trimEnd() + "...";
31}
32
33export default function computedFields({
34 nexus,
35}: {
36 nexus: typeof import("nexus");
37}) {
38 return {
39 types: [
40 nexus.extendType({
41 type: "Article",
42 definition(t) {
43 t.nonNull.int("wordCount", {
44 description: "Word count of the article description.",
45 resolve: (parent: ArticleSource) =>
46 countWords(parent?.description ?? ""),
47 });
48 },
49 }),
50
51 nexus.extendType({
52 type: "Note",
53 definition(t) {
54 t.nonNull.int("wordCount", {
55 description: "Word count of the note body (markdown stripped).",
56 resolve: (parent: NoteSource) =>
57 countWords(stripMarkdown(parent?.content ?? "")),
58 });
59
60 t.nonNull.int("readingTime", {
61 description: `Estimated reading time in minutes (${WORDS_PER_MINUTE} wpm).`,
62 resolve: (parent: NoteSource) => {
63 const words = countWords(stripMarkdown(parent?.content ?? ""));
64 return Math.max(1, Math.ceil(words / WORDS_PER_MINUTE));
65 },
66 });
67
68 t.nonNull.string("excerpt", {
69 description: "First N characters of the note, markdown stripped.",
70 args: { length: nexus.intArg({ default: DEFAULT_EXCERPT_LENGTH }) },
71 resolve: (parent: NoteSource, { length }: { length: number }) =>
72 truncateAt(stripMarkdown(parent?.content ?? ""), length),
73 });
74 },
75 }),
76 ],
77 resolversConfig: {
78 "Article.wordCount": { auth: false },
79 "Note.wordCount": { auth: false },
80 "Note.readingTime": { auth: false },
81 "Note.excerpt": { auth: false },
82 },
83 };
84}A computed-fields factory returns an object with two keys:
types is an array of type definitions written with Nexus. At startup, the GraphQL plugin collects every entry from every factory and stitches them all into one schema. The call nexus.extendType({ type: "Note", definition(t) { ... } }) reads "find the existing Note type and add these fields to it." The same pattern works on any type Shadow CRUD generated, like Article or Tag.resolversConfig sets per-resolver options. For computed fields this is almost always just auth: false on each added field (more on that below).Inside definition(t), each field is declared as t.nonNull.<scalar>("fieldName", { ... }). nonNull marks the field as required in the schema, so the value is never null and a client can read it without checking for null. Every field takes a description that shows up in the Sandbox schema panel and in generated client types.
The two nexus.extendType calls are independent. Article's wordCount runs off the description string. Note's three fields run off the markdown string in content. stripMarkdown removes code fences, inline code, images, links, headings, list bullets, blockquote markers, and bold/italic markers, so the counts and excerpts reflect readable text rather than raw markdown.
auth: false is needed hereLook at the bottom of the file: every computed field is marked auth: false. We did not need this on Query.notes in Step 6, because the Public role already has the find checkbox checked for Note in Settings → Users & Permissions Plugin → Roles → Public.
Here is what would happen without auth: false on a computed field. Before the resolver runs, the GraphQL plugin asks the Users & Permissions plugin: "does the current role have permission to call this?" For Query.notes it looks for api::note.note.find, which exists in the admin UI as a checkbox. Match found, request allowed.
For Note.wordCount it would look for api::note.note.wordCount. That permission does not exist. The admin UI only has checkboxes for the five built-in actions: find, findOne, create, update, delete. There is nowhere to grant wordCount. The lookup fails and the request comes back as Forbidden access.
auth: false tells the plugin to skip that lookup for the field. The field then runs whenever the parent object's own resolver runs. That is the behavior we want: if a caller is allowed to read a Note, they are allowed to read Note.wordCount on that same note. There is no separate "may they read the word count" question to answer.
Rule of thumb: any field you add with nexus.extendType on an existing content type needs auth: false in resolversConfig. The exception is when you want a custom rule to decide whether the field can be read, in which case you would attach a policy or middleware to that field instead. (None of the computed fields in this step do that, but Part 4's is-note-owner policy is an example of attaching a custom rule to a resolver.)
From the Sandbox, the new fields are selectable on any Note:
1query ComputedNoteFields {
2 notes(pagination: { pageSize: 3 }) {
3 title
4 wordCount
5 readingTime
6 excerpt(length: 60)
7 }
8}Every note should return a wordCount that matches its content (zero only for an empty note) and a readingTime of at least 1 (the resolver clamps to 1 via Math.max). If wordCount is 0 across the board, the resolver is being called but content is empty or null. Open a note in the admin UI and check that there is actual text in the markdown editor, or query notes { content } and look at the raw string.
Step 7 used nexus.extendType to add fields to types Shadow CRUD had already generated. But not every response is a Note or a Tag. Sometimes a resolver returns something that does not match any row in the database:
{ total: 42, published: 30, draft: 12 }.[{ tagName: "Work", count: 7 }, ...].{ success: true, conflicts: [...] }.For each of these you need a new GraphQL type. nexus.objectType is the API for declaring one.
So when do you use which?
nexus.extendType: add a field to a type that already exists. Used in Step 7 for Note.wordCount, Note.readingTime, Note.excerpt.nexus.objectType: declare a brand-new type. Used here for TagCount and NoteStats.The noteStats query in the next step returns three counts (total, pinned, archived) plus a per-tag breakdown. Neither return value matches a content type, so we declare two new types: NoteStats for the wrapper, and TagCount for each item in the per-tag list.
Open src/extensions/graphql/queries.ts. Part 1 created it with a single searchArticles query. You will add two nexus.objectType entries and three new Query fields alongside the existing one. The two object types come first; the query resolvers come in Step 9.
Two terms worth defining first; they come up several times below.
SDL stands for Schema Definition Language. It is GraphQL's standard syntax for describing a schema, the same in any language and any framework. It looks like this:
1type TagCount {
2 slug: String!
3 name: String!
4 count: Int!
5}SDL is what a GraphQL client sees when it asks the server "what does your schema look like" (an introspection query, like the one we ran in Step 2). It is also what tools like Apollo Sandbox, GraphQL Code Generator, and IDE plugins read to give you autocomplete and type information. Every GraphQL server, no matter how it builds its schema internally, eventually returns it as SDL.
Nexus is the TypeScript library that Strapi's GraphQL plugin uses to build that SDL from code, instead of asking you to write the SDL out as a string. Instead of typing the SDL above directly, you write:
1nexus.objectType({
2 name: "TagCount",
3 definition(t) {
4 t.nonNull.string("slug");
5 t.nonNull.string("name");
6 t.nonNull.int("count");
7 },
8});At startup, Nexus collects every objectType and extendType call across all your factory files and produces one SDL schema from them. That SDL is what gets handed to Apollo Server and what clients see. The end result is identical SDL; the difference is just how you wrote it.
Why Strapi uses Nexus instead of SDL strings:
resolve: (parent: NoteSource) => ..., TypeScript already knows the return value has to match the field type you declared with t.nonNull.int(...). If you wrote the schema as SDL strings, you would need an extra build step that reads the SDL and generates matching TypeScript types. Nexus skips that step.computed-fields.ts, queries.ts, and mutations.ts each return their own Nexus types, and Nexus merges them at startup. With SDL strings, two files cannot both add fields to the same Note type without extra glue.objectType and extendType to build the auto-generated schema. Your hand-written extensions use the same API, so there is one mental model instead of two.The tradeoff: Nexus is a little more verbose than raw SDL, and you have to read several files to see what the final schema looks like. That is why every new type in this step is shown alongside its SDL equivalent. The SDL block is what clients will actually see.
Part 1 covered the minimum Nexus you need for searchArticles. Two more pieces show up in this post.
Defining a new object type with nexus.objectType. Inside the call, the definition(t) callback declares each field on the type. The whole objectType call goes inside the types array your factory returns (the same array you saw in computed-fields.ts in Step 7). The pattern is always the same:
1export default function queries({ nexus, strapi }: { ... }) {
2 return {
3 types: [
4 // nexus.objectType({ ... }) <- new types go here
5 // nexus.extendType({ ... }) <- field extensions go here too
6 ],
7 resolversConfig: { /* ... */ },
8 };
9}Step 9 shows the full queries.ts with both TagCount and NoteStats declared in the types array alongside the existing searchArticles extension. For now, look at one object type on its own:
1nexus.objectType({
2 name: "TagCount",
3 definition(t) {
4 t.nonNull.string("slug");
5 t.nonNull.string("name");
6 t.nonNull.int("count");
7 },
8});The resulting SDL equivalent:
1type TagCount {
2 slug: String!
3 name: String!
4 count: Int!
5}Modifiers stack left to right. You can chain .nonNull (the field is required, never null) and .list (the field is an array) in front of the field type. The order matches what the SDL would say, read left to right:
| Nexus call | GraphQL type |
|---|---|
t.string('a') | a: String |
t.nonNull.string('a') | a: String! |
t.list.string('a') | a: [String] |
t.list.nonNull.string('a') | a: [String!] |
t.nonNull.list.nonNull.string('a') | a: [String!]! |
For object-typed fields, use t.field(name, { type }) or the chained forms (t.list.field, t.nonNull.field):
1t.nonNull.list.nonNull.field("byTag", { type: "TagCount" });
2// byTag: [TagCount!]!Type references by name. When a field's type is given as a string (type: 'Note', type: 'NoteStats'), Nexus looks up that name at startup against every type that has been declared. This is what lets different files reference each other without imports. queries.ts can reference 'Note' without importing anything from the Note files; by the time Nexus puts everything together, Note is already known. If you misspell the name, Nexus either throws an error at startup (strict mode) or returns null for that field at query time (lax mode).
Time to put this to work. Open src/extensions/graphql/queries.ts. Part 1 left it like this:
1// src/extensions/graphql/queries.ts (BEFORE)
2import type { Core } from "@strapi/strapi";
3
4export default function queries({
5 nexus,
6 strapi,
7}: {
8 nexus: typeof import("nexus");
9 strapi: Core.Strapi;
10}) {
11 return {
12 types: [
13 nexus.extendType({
14 type: "Query",
15 definition(t) {
16 t.list.field("searchArticles", {
17 type: nexus.nonNull("Article"),
18 args: { q: nexus.nonNull(nexus.stringArg()) },
19 async resolve(_parent: unknown, args: { q: string }) {
20 return strapi.documents("api::article.article").findMany({
21 filters: { title: { $containsi: args.q } },
22 sort: ["publishedAt:desc"],
23 status: "published",
24 });
25 },
26 });
27 },
28 }),
29 ],
30 resolversConfig: {
31 "Query.searchArticles": { auth: false },
32 },
33 };
34}Add TagCount as a sibling entry at the top of the types array, in front of the existing nexus.extendType({ type: "Query", ... }). Nothing else changes yet: not resolversConfig, not the existing searchArticles block, not the factory signature.
1// src/extensions/graphql/queries.ts (AFTER adding TagCount)
2import type { Core } from "@strapi/strapi";
3
4export default function queries({
5 nexus,
6 strapi,
7}: {
8 nexus: typeof import("nexus");
9 strapi: Core.Strapi;
10}) {
11 return {
12 types: [
13 // NEW: standalone object type for the per-tag breakdown in noteStats (Step 9).
14 nexus.objectType({
15 name: "TagCount",
16 definition(t) {
17 t.nonNull.string("slug");
18 t.nonNull.string("name");
19 t.nonNull.int("count");
20 },
21 }),
22
23 // Existing from Part 1.
24 nexus.extendType({
25 type: "Query",
26 definition(t) {
27 t.list.field("searchArticles", {
28 type: nexus.nonNull("Article"),
29 args: { q: nexus.nonNull(nexus.stringArg()) },
30 async resolve(_parent: unknown, args: { q: string }) {
31 return strapi.documents("api::article.article").findMany({
32 filters: { title: { $containsi: args.q } },
33 sort: ["publishedAt:desc"],
34 status: "published",
35 });
36 },
37 });
38 },
39 }),
40 ],
41 resolversConfig: {
42 "Query.searchArticles": { auth: false },
43 },
44 };
45}Three things to notice about the edit:
TagCount is a sibling entry in types, not nested inside the extendType block. Each entry in the types array is its own contribution to the schema. Nexus reads them as a flat list at startup.TagCount could come before or after the Query extension and the resulting schema would be identical. Nexus looks up type references by name once everything has been collected, so the order in the array does not affect correctness. Putting new object types first is a readability choice, not a requirement.resolversConfig is unchanged. TagCount has no resolver of its own; it is just a type declaration. The fields on it get filled in by whatever resolver returns a TagCount value, which is noteStats in Step 9. Object types only need entries in resolversConfig when you want to attach an auth, middleware, or policy rule to one of their fields, and we do not for TagCount.Save the file. Now a Nexus quirk worth knowing: if you introspect the schema for TagCount right now, you will get null.
curl -s -X POST http://localhost:1337/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"{ __type(name: \"TagCount\") { name } }"}'
# -> {"data":{"__type":null}}That is not a bug in your edit. Before exposing the final schema, the GraphQL plugin runs pruneSchema from @graphql-tools/utils. That helper walks the schema starting from Query, Mutation, and Subscription, and removes any type that nothing else reaches. No field anywhere has type: "TagCount" yet, so TagCount is unreachable and gets pruned.
TagCount will appear the moment something references it. That happens in Step 9, when we add NoteStats.byTag: [TagCount!]! and the noteStats query that returns a NoteStats. Until then the declaration sits in the file with nothing to use it. That is fine: you can build a schema piece by piece, and the parts that nobody uses yet just stay invisible.
If you want to confirm the TagCount declaration is at least syntactically correct right now, the only evidence available is that the dev server restarted without a TypeScript error after the save. A typo in the name or a misspelled modifier would crash Strapi on reload. The full check happens at the end of Step 9.
Step 9 adds three query resolvers to the same queries.ts file, plus one more object type (NoteStats). All three resolvers read data using the Document Service, which is Strapi's main API for working with content. One of them also has a side note showing the same query written as raw SQL.
Strapi gives you three ways to read or write data inside a resolver. Use the first one by default. The other two exist for the rare case when the Document Service cannot do what you need.
| API | When to use |
|---|---|
strapi.documents('api::foo.foo') | Default. Use for reads, writes, and filtered counts. Respects Draft & Publish, locales, and lifecycle hooks. |
strapi.db.query('api::foo.foo') | The Query Engine. Use only when you need to skip something the Document Service does for you (skipping lifecycle hooks in a bulk seed script, ignoring draft/publish, using a database operator the Document Service has not added yet). |
strapi.db.connection.raw | Direct SQL via Knex. Use only when the Document Service cannot express the query: grouped aggregates, window functions, joins across many tables, database-specific features. |
For almost everything, use the Document Service. The Query Engine is for specific cases, not the default. Raw SQL is the last resort. The four sub-steps below each make one small edit to queries.ts. By the end, the file matches the full version printed at the bottom of Step 9.
NoteStats object typeNoteStats is the return type for the noteStats query. It has three integer counts (total, pinned, archived) and a list of TagCount items for the per-tag breakdown. Because it references TagCount (which you declared in Step 8), TagCount is finally reachable from a real query and Nexus stops pruning it from the schema.
Open src/extensions/graphql/queries.ts. Paste this nexus.objectType call as a sibling entry in the types array, right after the existing TagCount declaration:
1nexus.objectType({
2 name: "NoteStats",
3 definition(t) {
4 t.nonNull.int("total");
5 t.nonNull.int("pinned");
6 t.nonNull.int("archived");
7 t.nonNull.list.nonNull.field("byTag", { type: "TagCount" });
8 },
9}),The one line worth pausing on is t.nonNull.list.nonNull.field("byTag", { type: "TagCount" }). Reading the chain left to right: "a non-null list of non-null TagCount". The SDL equivalent is byTag: [TagCount!]!. See the modifier table in Step 8 if you need a refresher.
NoteStats refers to TagCount by the string "TagCount". At startup, Nexus looks up every type by name across all the factories. TagCount was declared in the same types array, so the lookup succeeds. Both types now have at least one place that uses them: NoteStats uses TagCount, and the noteStats query (which we add in 9.3) will use NoteStats. Neither will be pruned from the final schema.
searchNotes (Document Service API)searchNotes filters notes by a substring of the title and lets the caller decide whether to include archived notes. It uses the Document Service, strapi.documents("api::note.note"). This is the same API the table at the top of Step 9 listed as the default; for any query that does not need to drop down to the Query Engine or raw SQL, this is the one to use.
Add one new field, searchNotes, to the Query extendType, just below the existing searchArticles field. After the edit, the whole Query extendType block in queries.ts should look like this:
1nexus.extendType({
2 type: "Query",
3 definition(t) {
4 t.list.field("searchArticles", {
5 type: nexus.nonNull("Article"),
6 args: { q: nexus.nonNull(nexus.stringArg()) },
7 async resolve(_parent: unknown, args: { q: string }) {
8 return strapi.documents("api::article.article").findMany({
9 filters: { title: { $containsi: args.q } },
10 sort: ["publishedAt:desc"],
11 status: "published",
12 });
13 },
14 });
15
16 // NEW below, everything above stays exactly as-is.
17 t.list.field("searchNotes", {
18 type: nexus.nonNull("Note"),
19 args: {
20 query: nexus.nonNull(nexus.stringArg()),
21 includeArchived: nexus.booleanArg({ default: false }),
22 },
23 async resolve(
24 _parent: unknown,
25 { query, includeArchived }: { query: string; includeArchived: boolean },
26 ) {
27 const where: any = { title: { $containsi: query } };
28 if (!includeArchived) where.archived = false;
29 return strapi.documents("api::note.note").findMany({
30 filters: where,
31 populate: ["tags"],
32 sort: ["pinned:desc", "updatedAt:desc"],
33 });
34 },
35 });
36 },
37}),Three things to notice about the new field:
includeArchived defaults to false. A caller who passes nothing for this argument gets back only active notes. They have to explicitly pass includeArchived: true to see archived rows. The default protects callers from accidentally getting deleted-looking notes mixed in with the live ones.populate: ["tags"]. This tells the Document Service to load the related tags for each note and include them in the response. Without it, every note in the result comes back with tags: undefined, and a client that selected tags { name } would see empty arrays even though the relations exist in the database.sort: ["pinned:desc", "updatedAt:desc"]. Pinned notes come first; within each group, the most recently edited note comes first. Same sort syntax as Strapi's REST API.Then add the resolversConfig entry. Your resolversConfig object at the bottom of the file now has two keys instead of one:
1resolversConfig: {
2 "Query.searchArticles": { auth: false },
3 "Query.searchNotes": { auth: false }, // NEW
4},noteStats (Document Service, with a raw-SQL aside)noteStats returns three counts plus a per-tag breakdown. Both halves use the Document Service:
strapi.documents("api::note.note").count({ filters: ... }).strapi.documents("api::tag.tag").findMany({ populate: ["notes"] }). We then count tag.notes.length for each tag in plain JavaScript.At the end of this sub-step there is also a side note showing the same per-tag count written as a single SQL query, for the case where the JavaScript version becomes slow on a very large dataset.
Add one more field, noteStats, to the Query extendType, just below searchNotes. The Query extendType now has three fields:
1nexus.extendType({
2 type: "Query",
3 definition(t) {
4 t.list.field("searchArticles", {
5 /* ... same as before ... */
6 });
7
8 t.list.field("searchNotes", {
9 /* ... same as Step 9.2 ... */
10 });
11
12 // NEW below, everything above stays exactly as-is.
13 t.nonNull.field("noteStats", {
14 type: "NoteStats",
15 async resolve() {
16 const [total, pinned, archived, tags] = await Promise.all([
17 strapi.documents("api::note.note").count({}),
18 strapi.documents("api::note.note").count({
19 filters: { pinned: true },
20 }),
21 strapi.documents("api::note.note").count({
22 filters: { archived: true },
23 }),
24 strapi.documents("api::tag.tag").findMany({
25 populate: ["notes"],
26 sort: ["name:asc"],
27 }),
28 ]);
29
30 const byTag = tags
31 .map((tag: any) => ({
32 slug: tag.slug,
33 name: tag.name,
34 count: Array.isArray(tag.notes) ? tag.notes.length : 0,
35 }))
36 .sort(
37 (a, b) => b.count - a.count || a.name.localeCompare(b.name),
38 );
39
40 return { total, pinned, archived, byTag };
41 },
42 });
43 },
44}),A few notes on the implementation:
strapi.documents(...).count({ filters: ... }): three counts, one per filter. The Document Service docs confirm count takes the same filters argument as findMany. Going through the Document Service also means these counts will respect Draft & Publish if you ever turn that setting back on for Note.strapi.documents("api::tag.tag").findMany({ populate: ["notes"] }): loads every Tag with its linked notes attached. The resolver then reads tag.notes.length to get the count for each tag. The .sort() call after the .map() orders the results by count descending, with name as a tie-breaker.Promise.all runs all four database calls at the same time instead of one after the other. The four results do not depend on each other, so there is no reason for them to wait their turn.When would you use
strapi.db.query(...)instead? The Query Engine is useful when you specifically need to skip something the Document Service does for you. Three examples: skipping lifecycle hooks in a bulk seed script, ignoring draft/publish and locale resolution, or filtering with a database-specific operator that the Document Service has not added yet. For a filtered count on a normal content type, the Document Service is the right tool.
The version above loads every Tag row, plus all of its linked Notes, and then counts in JavaScript. For a few dozen tags and a few hundred notes, that is fine. If you ever end up with thousands of tags and millions of notes, the database has to send all that note data over the wire just so the resolver can count it. At that point, doing the count in SQL avoids the round-trip cost. For reference, here is the per-tag count as a single SQL query:
1// Replace the `tags` fetch and the `byTag` mapping above with this, if the
2// populate-based approach becomes measurably slow on your dataset.
3const rows = await strapi.db.connection.raw(`
4 SELECT tags.slug AS slug, tags.name AS name, COUNT(link.note_id) AS count
5 FROM tags
6 LEFT JOIN notes_tags_lnk link ON link.tag_id = tags.id
7 GROUP BY tags.id
8 ORDER BY count DESC, tags.name ASC
9`);
10
11const byTag = (Array.isArray(rows) ? rows : []).map((r: any) => ({
12 slug: r.slug,
13 name: r.name,
14 count: Number(r.count ?? 0),
15}));Don't copy this version into the resolver unless you have actually measured a slowness problem. Raw SQL skips validation, lifecycle hooks, Draft & Publish handling, and any future improvements to Strapi's higher-level APIs. The link-table name notes_tags_lnk is a Strapi internal, not a documented API, and a future Strapi version could rename it. Use raw SQL only when the Document Service truly cannot express what you need, not because typing SQL feels faster.
One more thing worth knowing: strapi.db.connection.raw(...) returns whatever the underlying Knex driver returns, and the return value is different from one database to another. SQLite (better-sqlite3, which Part 1 set up by default) returns a plain array of row objects, which is the Array.isArray(rows) branch in the code above. PostgreSQL returns an object like { rows: [...], ... }, so with that driver you would read rows.rows instead, and the Array.isArray(...) ? ... : [] fallback above would quietly drop all your data. If you switch databases, update the unwrapping code.
Your resolversConfig object now has three keys:
1resolversConfig: {
2 "Query.searchArticles": { auth: false },
3 "Query.searchNotes": { auth: false },
4 "Query.noteStats": { auth: false }, // NEW
5},notesByTag (nested relation filter)notesByTag returns every active (non-archived) note that has a given tag, with pinned notes first. The resolver itself is a single Document Service call. The interesting part is how the filter walks across the relation.
Add the final field, notesByTag, to the Query extendType, below noteStats. All four Query fields are now in place:
1nexus.extendType({
2 type: "Query",
3 definition(t) {
4 t.list.field("searchArticles", {
5 /* ... same as before ... */
6 });
7
8 t.list.field("searchNotes", {
9 /* ... same as Step 9.2 ... */
10 });
11
12 t.nonNull.field("noteStats", {
13 /* ... same as Step 9.3 ... */
14 });
15
16 // NEW below, everything above stays exactly as-is.
17 t.list.field("notesByTag", {
18 type: nexus.nonNull("Note"),
19 args: { slug: nexus.nonNull(nexus.stringArg()) },
20 async resolve(_parent: unknown, { slug }: { slug: string }) {
21 return strapi.documents("api::note.note").findMany({
22 filters: { archived: false, tags: { slug: { $eq: slug } } },
23 populate: ["tags"],
24 sort: ["pinned:desc", "updatedAt:desc"],
25 });
26 },
27 });
28 },
29}),The filter tags: { slug: { $eq: slug } } reads "match every note that has at least one related tag whose slug equals the argument I passed in." This is the same nested filter syntax Shadow CRUD already exposes on the auto-generated notes(filters: ...) query. The Document Service and Shadow CRUD use the same filter syntax everywhere, so once you know one you know the other.
The final state of resolversConfig:
1resolversConfig: {
2 "Query.searchArticles": { auth: false },
3 "Query.searchNotes": { auth: false },
4 "Query.noteStats": { auth: false },
5 "Query.notesByTag": { auth: false }, // NEW
6},After all four sub-steps, src/extensions/graphql/queries.ts should look like this end-to-end:
1// src/extensions/graphql/queries.ts
2import type { Core } from "@strapi/strapi";
3
4export default function queries({
5 nexus,
6 strapi,
7}: {
8 nexus: typeof import("nexus");
9 strapi: Core.Strapi;
10}) {
11 return {
12 types: [
13 nexus.objectType({
14 name: "TagCount",
15 definition(t) {
16 t.nonNull.string("slug");
17 t.nonNull.string("name");
18 t.nonNull.int("count");
19 },
20 }),
21 nexus.objectType({
22 name: "NoteStats",
23 definition(t) {
24 t.nonNull.int("total");
25 t.nonNull.int("pinned");
26 t.nonNull.int("archived");
27 t.nonNull.list.nonNull.field("byTag", { type: "TagCount" });
28 },
29 }),
30 nexus.extendType({
31 type: "Query",
32 definition(t) {
33 t.list.field("searchArticles", {
34 type: nexus.nonNull("Article"),
35 args: { q: nexus.nonNull(nexus.stringArg()) },
36 async resolve(_parent: unknown, args: { q: string }) {
37 return strapi.documents("api::article.article").findMany({
38 filters: { title: { $containsi: args.q } },
39 sort: ["publishedAt:desc"],
40 status: "published",
41 });
42 },
43 });
44
45 t.list.field("searchNotes", {
46 type: nexus.nonNull("Note"),
47 args: {
48 query: nexus.nonNull(nexus.stringArg()),
49 includeArchived: nexus.booleanArg({ default: false }),
50 },
51 async resolve(
52 _parent: unknown,
53 {
54 query,
55 includeArchived,
56 }: { query: string; includeArchived: boolean },
57 ) {
58 const where: any = { title: { $containsi: query } };
59 if (!includeArchived) where.archived = false;
60 return strapi.documents("api::note.note").findMany({
61 filters: where,
62 populate: ["tags"],
63 sort: ["pinned:desc", "updatedAt:desc"],
64 });
65 },
66 });
67
68 t.nonNull.field("noteStats", {
69 type: "NoteStats",
70 async resolve() {
71 const [total, pinned, archived, tags] = await Promise.all([
72 strapi.documents("api::note.note").count({}),
73 strapi.documents("api::note.note").count({
74 filters: { pinned: true },
75 }),
76 strapi.documents("api::note.note").count({
77 filters: { archived: true },
78 }),
79 strapi.documents("api::tag.tag").findMany({
80 populate: ["notes"],
81 sort: ["name:asc"],
82 }),
83 ]);
84
85 const byTag = tags
86 .map((tag: any) => ({
87 slug: tag.slug,
88 name: tag.name,
89 count: Array.isArray(tag.notes) ? tag.notes.length : 0,
90 }))
91 .sort(
92 (a, b) => b.count - a.count || a.name.localeCompare(b.name),
93 );
94
95 return { total, pinned, archived, byTag };
96 },
97 });
98
99 t.list.field("notesByTag", {
100 type: nexus.nonNull("Note"),
101 args: { slug: nexus.nonNull(nexus.stringArg()) },
102 async resolve(_parent: unknown, { slug }: { slug: string }) {
103 return strapi.documents("api::note.note").findMany({
104 filters: { archived: false, tags: { slug: { $eq: slug } } },
105 populate: ["tags"],
106 sort: ["pinned:desc", "updatedAt:desc"],
107 });
108 },
109 });
110 },
111 }),
112 ],
113 resolversConfig: {
114 "Query.searchArticles": { auth: false },
115 "Query.searchNotes": { auth: false },
116 "Query.noteStats": { auth: false },
117 "Query.notesByTag": { auth: false },
118 },
119 };
120}Restart the dev server. The Sandbox's left-hand Schema panel should now show TagCount, NoteStats, searchNotes, noteStats, and notesByTag. A quick smoke test:
curl -s -X POST http://localhost:1337/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"{ noteStats { total pinned archived byTag { slug name count } } }"}'The response should include the three counts and a non-empty byTag array (assuming the seed data from Step 4 tagged some notes).
This tutorial keeps every custom query in queries.ts, every custom mutation in mutations.ts, and every computed field in computed-fields.ts. That is a role-based layout: one file per kind of code. It works well while each file is under about 200 lines and the project has a small number of content types with custom logic.
Once a file passes that threshold, or the project grows to many content types, the natural next step is a feature-based layout: one folder per content type.
1src/extensions/graphql/
2├── index.ts # aggregator
3├── note/
4│ ├── index.ts # barrel combining everything below
5│ ├── types.ts # TagCount, NoteStats
6│ ├── queries.ts # searchNotes, noteStats, notesByTag
7│ ├── mutations.ts # togglePin, archiveNote, duplicateNote
8│ └── computed-fields.ts # Note.wordCount, readingTime, excerpt
9├── article/
10│ └── ...
11└── shared/
12 └── types.ts # types used across multiple featuresEach feature file exports its own factory that returns its own nexus.extendType({ type: "Query" }) (or Mutation, or whatever it needs). Nexus is fine with the same type being extended in many places: at startup it gathers every extension of Query from every factory and merges them, so the feature files do not have to know about each other. The index.ts inside each feature folder pulls together the types, the resolversConfig, and any nested factories. The top-level index.ts registers each feature with the GraphQL plugin.
Four guidelines, whichever layout you pick:
{ types, resolversConfig }, or calls extensionService.use(...) directly. The top-level index.ts is the only place that calls extensionService.use(...) for everything in your project.TagCount is only used by NoteStats.byTag, and NoteStats is only returned by noteStats. So TagCount belongs in the Notes feature folder. Move a type into a shared/ folder only when more than one feature actually returns it.t.list.field("searchNotes", { ... }) is what the GraphQL plugin expects; if you wrap it in something like registerQuery(config), you lose TypeScript's inline type-checking on the resolver and you get nothing back in return.queries.ts is easier to read than a six-file feature folder where everything imports from everything else. Split when the file is genuinely hard to navigate, not before.For this tutorial (three content types, four custom queries, three custom mutations), the role-based layout is correct. Switch to feature folders once a single resolver file is over about 200 lines, or the project has three or four content types each with their own custom logic.
Custom mutations work the same way as custom queries. You call nexus.extendType on the Mutation type, add new fields, and each field has its own arguments and resolver. Create a new file:
1// src/extensions/graphql/mutations.ts
2import type { Core } from "@strapi/strapi";
3
4export default function mutations({
5 nexus,
6 strapi,
7}: {
8 nexus: typeof import("nexus");
9 strapi: Core.Strapi;
10}) {
11 return {
12 types: [
13 nexus.extendType({
14 type: "Mutation",
15 definition(t) {
16 t.field("togglePin", {
17 type: "Note",
18 args: { documentId: nexus.nonNull(nexus.idArg()) },
19 async resolve(
20 _parent: unknown,
21 { documentId }: { documentId: string },
22 ) {
23 const current = await strapi
24 .documents("api::note.note")
25 .findOne({ documentId });
26 if (!current) throw new Error(`Note ${documentId} not found`);
27 return strapi.documents("api::note.note").update({
28 documentId,
29 data: { pinned: !current.pinned },
30 populate: ["tags"],
31 });
32 },
33 });
34
35 t.field("archiveNote", {
36 type: "Note",
37 args: { documentId: nexus.nonNull(nexus.idArg()) },
38 async resolve(
39 _parent: unknown,
40 { documentId }: { documentId: string },
41 ) {
42 return strapi.documents("api::note.note").update({
43 documentId,
44 data: { archived: true, pinned: false },
45 populate: ["tags"],
46 });
47 },
48 });
49
50 t.field("duplicateNote", {
51 type: "Note",
52 args: { documentId: nexus.nonNull(nexus.idArg()) },
53 async resolve(
54 _parent: unknown,
55 { documentId }: { documentId: string },
56 ) {
57 const original = await strapi
58 .documents("api::note.note")
59 .findOne({
60 documentId,
61 populate: ["tags"],
62 });
63 if (!original) throw new Error(`Note ${documentId} not found`);
64 const tagIds = ((original as any).tags ?? [])
65 .map((tag: any) => tag.documentId)
66 .filter(Boolean);
67 return strapi.documents("api::note.note").create({
68 data: {
69 title: `${(original as any).title} (copy)`,
70 content: (original as any).content,
71 pinned: false,
72 archived: false,
73 tags: tagIds,
74 },
75 populate: ["tags"],
76 });
77 },
78 });
79 },
80 }),
81 ],
82 resolversConfig: {
83 "Mutation.togglePin": { auth: false },
84 "Mutation.archiveNote": { auth: false },
85 "Mutation.duplicateNote": { auth: false },
86 },
87 };
88}Register the new factory the same way the others are registered, in src/extensions/graphql/index.ts:
1// src/extensions/graphql/index.ts
2import type { Core } from "@strapi/strapi";
3import computedFields from "./computed-fields";
4import queries from "./queries";
5import mutations from "./mutations";
6import middlewaresAndPolicies from "./middlewares-and-policies";
7
8export default function registerGraphQLExtensions(strapi: Core.Strapi) {
9 const extensionService = strapi.plugin("graphql").service("extension");
10
11 extensionService.use(middlewaresAndPolicies);
12 extensionService.use(computedFields);
13 extensionService.use(function extendQueries({ nexus }: any) {
14 return queries({ nexus, strapi });
15 });
16 extensionService.use(function extendMutations({ nexus }: any) {
17 return mutations({ nexus, strapi });
18 });
19}Two things to notice about the three mutations:
findOne call. If togglePin did not return anything, the client would have no way to know the new value of pinned without a follow-up query. Every resolver above returns the Note it just created or updated.populate: ["tags"] to the Document Service. Without populate, the returned note has tags: undefined. A client that selects tags { name } would get tags: null back even if the note has tags in the database. Apollo Client would cache that null, and the UI in Part 3 would show the note with no tags until the cache is invalidated. Always populate the relations the client might select.Open the Sandbox at http://localhost:1337/graphql. The operations below cover every Shadow CRUD surface, every custom type, every custom query, every custom mutation, and the policy. Paste each into the Operation editor; paste any variables into the Variables panel (including the outer { ... } braces).
Prefer automation? The same set of checks is available as a single Node script. The full source is below; save it as
server/scripts/test-graphql.mjs.
Run node scripts/test-graphql.mjs from the server/ directory and you get a pass/fail summary for all 33 checks in about a second. Each manual walkthrough below still teaches something specific to the Sandbox UI, so skim them even if you rely on the script.
1// test-graphql.mjs
2// Automated validation of Part 2's GraphQL schema.
3// Usage: node scripts/test-graphql.mjs
4// Requires: Node 18+ and the Strapi dev server running on localhost:1337.
5
6const ENDPOINT = process.env.STRAPI_GRAPHQL_URL ?? "http://localhost:1337/graphql";
7
8let pass = 0;
9let fail = 0;
10const failed = [];
11
12const gql = async (query, variables, headers = {}) => {
13 const res = await fetch(ENDPOINT, {
14 method: "POST",
15 headers: { "Content-Type": "application/json", ...headers },
16 body: JSON.stringify({ query, variables }),
17 });
18 return res.json();
19};
20
21const check = (label, condition, detail = "") => {
22 if (condition) {
23 console.log(` ✓ ${label}`);
24 pass++;
25 } else {
26 const line = detail ? `${label} — ${detail}` : label;
27 console.log(` ✗ ${line}`);
28 failed.push(line);
29 fail++;
30 }
31};
32
33const section = (name) => console.log(`\n${name}`);
34
35async function main() {
36 // Server reachability
37 try {
38 const ping = await gql("{ __typename }");
39 if (!ping?.data) throw new Error("No data");
40 } catch (e) {
41 console.error(`Cannot reach ${ENDPOINT}. Is npm run develop running?`);
42 process.exit(2);
43 }
44
45 // 1. Shadow CRUD queries on Note and Tag
46 section("Shadow CRUD queries");
47 const active = await gql(
48 `{ notes(sort: ["pinned:desc","updatedAt:desc"]) {
49 documentId title pinned tags { name slug color }
50 } }`,
51 );
52 const activeNotes = active?.data?.notes ?? [];
53 check("List active notes returns an array", Array.isArray(activeNotes));
54 check(
55 "Active notes hydrate the tags relation",
56 activeNotes.some((n) => Array.isArray(n.tags)),
57 );
58
59 const firstNote = activeNotes[0];
60 if (!firstNote) {
61 console.error("\nNo active notes in the database. Seed at least one via the admin UI and re-run.");
62 process.exit(2);
63 }
64
65 // Select `content` too so we exercise the richtext → String mapping
66 // referenced by the detail page and Part 3 Step 4.
67 const single = await gql(
68 `query Note($documentId: ID!) {
69 note(documentId: $documentId) { documentId title content }
70 }`,
71 { documentId: firstNote.documentId },
72 );
73 check(
74 "Fetch by documentId works",
75 single?.data?.note?.documentId === firstNote.documentId,
76 );
77 check(
78 "note.content returns a string or null (richtext → String)",
79 single?.data?.note?.content === null ||
80 typeof single?.data?.note?.content === "string",
81 );
82
83 // Array-form sort, matching the corrected Sandbox example in Part 2.
84 const tagsResult = await gql(
85 `{ tags(sort: ["name:asc"]) { documentId name slug } }`,
86 );
87 const tagsList = tagsResult?.data?.tags ?? [];
88 check("List tags returns an array", Array.isArray(tagsList));
89
90 // Shadow CRUD mutations: createNote + updateNote
91 section("Shadow CRUD mutations");
92 const createdTitle = `Test note ${Date.now()}`;
93 const created = await gql(
94 `mutation CreateNote($data: NoteInput!) {
95 createNote(data: $data) { documentId title pinned archived }
96 }`,
97 {
98 data: {
99 title: createdTitle,
100 content: "Created by the validation script.",
101 pinned: false,
102 archived: false,
103 tags: [],
104 },
105 },
106 );
107 const createdId = created?.data?.createNote?.documentId;
108 check(
109 "createNote returns a new Note with the submitted title",
110 created?.data?.createNote?.title === createdTitle,
111 );
112
113 if (createdId) {
114 const updated = await gql(
115 `mutation UpdateNote($documentId: ID!, $data: NoteInput!) {
116 updateNote(documentId: $documentId, data: $data) { documentId title }
117 }`,
118 { documentId: createdId, data: { title: `${createdTitle} (updated)` } },
119 );
120 check(
121 "updateNote changes the title of an existing Note",
122 updated?.data?.updateNote?.title === `${createdTitle} (updated)`,
123 );
124
125 // Tag replacement: updateNote with a different `tags: [...]` array should
126 // overwrite the relation. Confirms the "full replacement" behavior the
127 // tutorial calls out in Part 3 Step 5.
128 if (tagsList[0]) {
129 await gql(
130 `mutation U($id: ID!, $data: NoteInput!) {
131 updateNote(documentId: $id, data: $data) { documentId }
132 }`,
133 { id: createdId, data: { tags: [tagsList[0].documentId] } },
134 );
135 const reread = await gql(
136 `query N($id: ID!) { note(documentId: $id) { tags { documentId } } }`,
137 { id: createdId },
138 );
139 const newTagIds = (reread?.data?.note?.tags ?? []).map(
140 (t) => t.documentId,
141 );
142 check(
143 "updateNote replaces the tags relation when a new array is passed",
144 newTagIds.length === 1 && newTagIds[0] === tagsList[0].documentId,
145 );
146 }
147
148 // Excerpt length argument: `excerpt(length: 10)` should respect the arg
149 // (up to 10 chars plus a "..." suffix when truncated).
150 const excerptCheck = await gql(
151 `query N($id: ID!) { note(documentId: $id) { excerpt(length: 10) } }`,
152 { id: createdId },
153 );
154 const ex = excerptCheck?.data?.note?.excerpt;
155 check(
156 "excerpt(length: 10) respects the argument",
157 typeof ex === "string" && ex.length <= 13,
158 );
159
160 // Archive on the primary test note (not just the duplicate from later).
161 const archivedDirect = await gql(
162 `mutation A($id: ID!) { archiveNote(documentId: $id) { archived } }`,
163 { id: createdId },
164 );
165 check(
166 "archiveNote sets archived=true on a fresh note",
167 archivedDirect?.data?.archiveNote?.archived === true,
168 );
169 }
170
171 // 2. Hidden-field confirmations (private: true)
172 section("Hidden fields (private: true)");
173 const hiddenOutput = await gql(`{ notes { internalNotes } }`);
174 check(
175 "internalNotes is not selectable on Note",
176 hiddenOutput?.errors?.some((e) =>
177 e.message.includes('Cannot query field "internalNotes"'),
178 ),
179 );
180
181 const hiddenFilter = await gql(
182 `{ notes(filters: { internalNotes: { containsi: "probe" } }) { documentId } }`,
183 );
184 check(
185 "internalNotes is absent from NoteFiltersInput",
186 hiddenFilter?.errors?.some((e) =>
187 e.message.includes('"internalNotes" is not defined by type "NoteFiltersInput"'),
188 ),
189 );
190
191 const hiddenInput = await gql(
192 `mutation N { createNote(data: { title: "x", internalNotes: "probe" }) { documentId } }`,
193 );
194 check(
195 "internalNotes is absent from NoteInput",
196 hiddenInput?.errors?.some((e) =>
197 e.message.includes('"internalNotes" is not defined by type "NoteInput"'),
198 ),
199 );
200
201 // 3. Computed fields
202 section("Computed fields");
203 const computed = await gql(
204 `{ notes(pagination: { pageSize: 3 }) { title wordCount readingTime excerpt(length: 60) } }`,
205 );
206 const cNotes = computed?.data?.notes ?? [];
207 check(
208 "wordCount is a number on every note",
209 cNotes.every((n) => typeof n.wordCount === "number"),
210 );
211 check(
212 "readingTime is a number on every note",
213 cNotes.every((n) => typeof n.readingTime === "number"),
214 );
215 check(
216 "excerpt is a string on every note",
217 cNotes.every((n) => typeof n.excerpt === "string"),
218 );
219
220 // 4. Custom queries
221 section("Custom queries");
222 const searchTerm = (firstNote.title ?? "").split(/\s+/)[0] || "a";
223 const search = await gql(
224 `query S($q: String!) { searchNotes(query: $q) { documentId title } }`,
225 { q: searchTerm },
226 );
227 check(
228 `searchNotes("${searchTerm}") returns at least one result`,
229 (search?.data?.searchNotes ?? []).length > 0,
230 );
231
232 const stats = await gql(
233 `{ noteStats { total pinned archived byTag { slug name count } } }`,
234 );
235 const s = stats?.data?.noteStats;
236 check(
237 "noteStats returns total/pinned/archived as numbers",
238 typeof s?.total === "number" &&
239 typeof s?.pinned === "number" &&
240 typeof s?.archived === "number",
241 );
242 check("noteStats.byTag is an array", Array.isArray(s?.byTag));
243
244 if (tagsList[0]) {
245 const byTag = await gql(
246 `query B($slug: String!) { notesByTag(slug: $slug) { documentId title } }`,
247 { slug: tagsList[0].slug },
248 );
249 check(
250 `notesByTag(slug: "${tagsList[0].slug}") returns an array`,
251 Array.isArray(byTag?.data?.notesByTag),
252 );
253
254 // notesByTag should exclude archived notes even when they have the tag.
255 const archivedProbeTitle = `Archived probe ${Date.now()}`;
256 const probe = await gql(
257 `mutation C($data: NoteInput!) {
258 createNote(data: $data) { documentId }
259 }`,
260 {
261 data: {
262 title: archivedProbeTitle,
263 content: "archived probe",
264 pinned: false,
265 archived: true,
266 tags: [tagsList[0].documentId],
267 },
268 },
269 );
270 const probeId = probe?.data?.createNote?.documentId;
271
272 const byTagAfter = await gql(
273 `query B($slug: String!) { notesByTag(slug: $slug) { documentId } }`,
274 { slug: tagsList[0].slug },
275 );
276 const ids = (byTagAfter?.data?.notesByTag ?? []).map((n) => n.documentId);
277 check(
278 "notesByTag excludes archived notes",
279 !!probeId && !ids.includes(probeId),
280 );
281 // Leave the probe archived; the next run will re-create (different title).
282 }
283
284 // 5. Custom mutations (toggles and duplicates; restores state on success)
285 section("Custom mutations");
286 const pinBefore = firstNote.pinned;
287 const toggle = await gql(
288 `mutation T($id: ID!) { togglePin(documentId: $id) { pinned } }`,
289 { id: firstNote.documentId },
290 );
291 check(
292 "togglePin flips the pinned flag",
293 toggle?.data?.togglePin?.pinned === !pinBefore,
294 );
295 // Restore original state.
296 await gql(`mutation T($id: ID!) { togglePin(documentId: $id) { pinned } }`, {
297 id: firstNote.documentId,
298 });
299
300 const dup = await gql(
301 `mutation D($id: ID!) { duplicateNote(documentId: $id) { documentId title } }`,
302 { id: firstNote.documentId },
303 );
304 const dupTitle = dup?.data?.duplicateNote?.title;
305 check(
306 "duplicateNote returns a new note titled '<original> (copy)'",
307 typeof dupTitle === "string" && dupTitle.endsWith("(copy)"),
308 );
309
310 if (dup?.data?.duplicateNote?.documentId) {
311 const archived = await gql(
312 `mutation A($id: ID!) { archiveNote(documentId: $id) { archived pinned } }`,
313 { id: dup.data.duplicateNote.documentId },
314 );
315 check(
316 "archiveNote sets archived=true and pinned=false on the duplicate",
317 archived?.data?.archiveNote?.archived === true &&
318 archived?.data?.archiveNote?.pinned === false,
319 );
320 }
321
322 // 6. Middleware: soft-delete invariant on Query.notes
323 section("Middleware: soft-delete invariant on Query.notes");
324
325 const bare = await gql(`{ notes { documentId archived } }`);
326 const bareNotes = bare?.data?.notes ?? [];
327 check(
328 "Bare notes query succeeds and returns no archived rows",
329 !bare?.errors && bareNotes.every((n) => n.archived === false),
330 );
331
332 const sneaky = await gql(
333 `{ notes(filters: { archived: { eq: true } }) { documentId } }`,
334 );
335 check(
336 "Caller-supplied `archived: { eq: true }` is rejected",
337 sneaky?.errors?.some((e) => /archived/i.test(e.message)) &&
338 !sneaky?.data?.notes,
339 );
340 check(
341 "Rejection middleware surfaces extensions.code: FORBIDDEN",
342 sneaky?.errors?.some((e) => e.extensions?.code === "FORBIDDEN"),
343 );
344
345 const polite = await gql(
346 `{ notes(filters: { archived: { eq: false } }) { documentId } }`,
347 );
348 check(
349 "Caller-supplied `archived: { eq: false }` is also rejected",
350 polite?.errors?.some((e) => /archived/i.test(e.message)) &&
351 !polite?.data?.notes,
352 );
353
354 // 7. Policy: cap-page-size on Query.notes
355 section("Policy: cap-page-size");
356
357 const overCap = await gql(
358 `{ notes(pagination: { pageSize: 500 }) { documentId } }`,
359 );
360 check(
361 "Pagination over the cap is rejected (Policy Failed)",
362 overCap?.errors?.some((e) => e.message.includes("Policy Failed")),
363 );
364
365 const underCap = await gql(
366 `{ notes(pagination: { pageSize: 10 }) { documentId } }`,
367 );
368 check(
369 "Pagination at/under the cap is allowed",
370 !underCap?.errors,
371 );
372
373 // 8. Middleware: soft-delete on Query.note (single fetch by documentId)
374 section("Middleware: soft-delete on Query.note");
375
376 const probeCreate = await gql(
377 `mutation N { createNote(data: { title: "soft-delete probe ${Date.now()}", content: "probe" }) { documentId } }`,
378 );
379 const probeId = probeCreate?.data?.createNote?.documentId;
380 if (probeId) {
381 await gql(
382 `mutation A($id: ID!) { archiveNote(documentId: $id) { archived } }`,
383 { id: probeId },
384 );
385
386 const archivedFetch = await gql(
387 `query F($id: ID!) { note(documentId: $id) { documentId title archived } }`,
388 { id: probeId },
389 );
390 check(
391 "Direct fetch of an archived note returns NotFound",
392 archivedFetch?.errors?.some((e) => /not found/i.test(e.message)) &&
393 !archivedFetch?.data?.note,
394 );
395 check(
396 "Single-fetch coverage surfaces extensions.code: STRAPI_NOT_FOUND_ERROR",
397 archivedFetch?.errors?.some(
398 (e) => e.extensions?.code === "STRAPI_NOT_FOUND_ERROR",
399 ),
400 );
401
402 const activeFetch = await gql(
403 `query F($id: ID!) { note(documentId: $id) { documentId } }`,
404 { id: firstNote.documentId },
405 );
406 check(
407 "Direct fetch of an active note still works",
408 !activeFetch?.errors && activeFetch?.data?.note?.documentId,
409 );
410 } else {
411 check("Probe note created for soft-delete test", false, "createNote returned no documentId");
412 }
413
414 // Summary
415 console.log(`\n${pass} passed, ${fail} failed`);
416 if (failed.length) {
417 console.log("\nFailures:");
418 failed.forEach((f) => console.log(` • ${f}`));
419 }
420 process.exit(fail === 0 ? 0 : 1);
421}
422
423main().catch((err) => {
424 console.error(err);
425 process.exit(1);
426});List active notes, sorted by pinned then recency.
1query ActiveNotes {
2 notes(
3 filters: { archived: { eq: false } }
4 sort: ["pinned:desc", "updatedAt:desc"]
5 ) {
6 documentId
7 title
8 pinned
9 tags {
10 name
11 slug
12 color
13 }
14 }
15}Fetch a single note by documentId.
1query Note($documentId: ID!) {
2 note(documentId: $documentId) {
3 documentId
4 title
5 content
6 tags {
7 name
8 slug
9 }
10 }
11}Variables:
1{ "documentId": "paste-a-real-documentId-here" }Grab a documentId from the previous query's response and paste it into the Variables panel.
List tags.
1query Tags {
2 tags(sort: ["name:asc"]) {
3 documentId
4 name
5 slug
6 color
7 }
8}Create a note. data uses the generated NoteInput type. content is a Markdown string (since we declared the field as richtext in Step 2). Tags are referenced by their documentId.
1mutation CreateNote($data: NoteInput!) {
2 createNote(data: $data) {
3 documentId
4 title
5 }
6}Variables:
1{
2 "data": {
3 "title": "Testing from the Sandbox",
4 "content": "Hello from Apollo Sandbox.\n\nA second paragraph.",
5 "pinned": false,
6 "archived": false,
7 "tags": []
8 }
9}Update a note.
1mutation UpdateNote($documentId: ID!, $data: NoteInput!) {
2 updateNote(documentId: $documentId, data: $data) {
3 documentId
4 title
5 }
6}Variables:
1{
2 "documentId": "paste-a-real-documentId-here",
3 "data": { "title": "Updated title" }
4}Note on deleteNote. Mutation.deleteNote still exists in the schema. We did not apply any Shadow CRUD customization, following Step 5's argument that permissions (not schema-level deletion) are the standard way to prevent unwanted actions. Because Step 3 left delete unchecked on the Public role, calling mutation { deleteNote(documentId: "...") { documentId } } from the Sandbox returns Forbidden access at runtime, not a schema error. If you also want the mutation gone from introspection, add it back as a single disableAction('delete') call in a shadow-crud.ts factory.
Querying internalNotes on a note should fail validation:
1query {
2 notes {
3 documentId
4 internalNotes
5 }
6}Expected error: Cannot query field "internalNotes" on type "Note".. If the field were still selectable, the private: true flag set on internalNotes in Step 2 would not be taking effect. (That flag is what hides it from the GraphQL output type. Shadow CRUD's disableOutput() is the alternative covered conceptually in Step 5.)
Similarly, trying to filter on it should fail:
1query { notes(filters: { internalNotes: { $containsi: "probe" } }) { documentId } }Expected error: Field "internalNotes" is not defined by type "NoteFiltersInput".. This confirms disableFilters().
1query ComputedFields {
2 notes(pagination: { pageSize: 3 }) {
3 title
4 wordCount
5 readingTime
6 excerpt(length: 60)
7 }
8}Every note should return non-null values for all three fields.
searchNotes, title search across active notes.
1query SearchNotes($q: String!) {
2 searchNotes(query: $q) {
3 documentId
4 title
5 excerpt(length: 80)
6 }
7}Variables:
1{ "q": "review" }Substitute a word that matches the titles you created in Step 4.
noteStats, aggregate counts with per-tag breakdown.
1query NoteStats {
2 noteStats {
3 total
4 pinned
5 archived
6 byTag {
7 slug
8 name
9 count
10 }
11 }
12}notesByTag, notes for a given tag slug.
1query NotesByTag($slug: String!) {
2 notesByTag(slug: $slug) {
3 documentId
4 title
5 pinned
6 }
7}Variables:
1{ "slug": "work" }togglePin. Flips the pinned flag and returns the updated note.
1mutation TogglePin($documentId: ID!) {
2 togglePin(documentId: $documentId) {
3 documentId
4 pinned
5 }
6}archiveNote. Sets archived: true and pinned: false.
1mutation ArchiveNote($documentId: ID!) {
2 archiveNote(documentId: $documentId) {
3 documentId
4 archived
5 pinned
6 }
7}duplicateNote. Creates a new row with the same content and tags, title suffixed with (copy).
1mutation DuplicateNote($documentId: ID!) {
2 duplicateNote(documentId: $documentId) {
3 documentId
4 title
5 tags {
6 name
7 }
8 }
9}All three take the same variable shape:
1{ "documentId": "paste-a-real-documentId-here" }Four quick checks confirm that the soft-delete middlewares (on both resolvers) and the page-cap policy from Step 6 behave the way the curl smoke tests showed.
Soft-delete rejection on Query.notes. Run a query that explicitly filters on archived:
1query {
2 notes(filters: { archived: { eq: true } }) {
3 title
4 }
5}The response contains a FORBIDDEN error with the message Cannot filter on \archived` directly. .... The same query witharchived: { eq: false }` is also rejected, because the rule is "the server alone manages archived." There is no header or other escape hatch.
Soft-delete default. Run a bare query:
1query {
2 notes {
3 title
4 archived
5 }
6}The response is a 200 OK and every entry has archived: false. The injection middleware added the filter automatically.
Soft-delete coverage on Query.note. Archive a note in the admin UI (or via the archiveNote mutation), copy its documentId, and run:
1query F($id: ID!) {
2 note(documentId: $id) {
3 documentId
4 title
5 archived
6 }
7}with the variable { "id": "<archived-documentId>" } in the Variables panel. The response contains a NOT_FOUND error with the message Note not found.. Replace the variable with an active note's documentId and the same query returns the row. From the public API's point of view, the archived note does not exist on either read path.
Page-size cap. Run a query that asks for more than 100 rows in one page:
1query {
2 notes(pagination: { pageSize: 500 }) {
3 documentId
4 }
5}The response contains Policy Failed. Drop the page size to 10 and the same query succeeds.
The Sandbox's left panel is populated by the same introspection query any GraphQL tool would use. Expand it to confirm the schema matches what we built:
Query, the custom fields searchNotes, noteStats, and notesByTag appear alongside the Shadow-CRUD-generated notes, note, tags, tag, and searchArticles.Mutation, the custom mutations togglePin, archiveNote, and duplicateNote appear alongside the Shadow-CRUD-generated createNote, updateNote, and deleteNote.NoteStats and TagCount appear as standalone object types.Note, and internalNotes is absent from the fields list because of the private: true flag from Step 2.NoteFiltersInput, and internalNotes is likewise absent from the filter fields.If any of the above does not match, the corresponding Step 2, 7, 8, 9, or 10 change did not take effect. Restart the dev server and re-check; the server needs to rebuild the Nexus schema after every change in src/extensions/graphql/ or the content-type schema.json files.
internalNotes flagged as private: true so Strapi hides it from both REST and GraphQL.middlewares-and-policies.ts factory that attaches three middlewares (soft-delete rejection, soft-delete injection, timing log) and one named policy (global::cap-page-size) to Query.notes, plus one soft-delete coverage middleware to Query.note. Together they enforce the invariant that the public GraphQL API cannot return archived rows from either the list path or the single-fetch path. The page-size policy lives in src/policies/cap-page-size.ts.Note (wordCount, readingTime, excerpt) added to the existing computed-fields.ts.TagCount, NoteStats) and three custom queries (searchNotes, noteStats, notesByTag) added to the existing queries.ts. The resolvers use the Document Service throughout, with a raw-SQL aside for the per-tag aggregate in noteStats.mutations.ts factory with three mutations (togglePin, archiveNote, duplicateNote).The final file layout under server/:
1server/
2├── config/
3│ └── plugins.ts
4└── src/
5 ├── index.ts
6 ├── policies/
7 │ └── cap-page-size.ts
8 └── extensions/
9 └── graphql/
10 ├── index.ts # aggregator
11 ├── middlewares-and-policies.ts
12 ├── computed-fields.ts # Article.wordCount + Note fields
13 ├── queries.ts # searchArticles + Note queries
14 └── mutations.tsEvery real customization API the GraphQL plugin is likely to need in a production project has now been exercised at least once: resolversConfig with both middlewares and policies, new object types, computed fields, custom queries at three levels of data-access abstraction, and custom mutations. Shadow CRUD customization was covered conceptually in Step 5 but not wired into the code, because in practice permissions and private: true cover that ground.
This is Part 2 of a four-part series.
Part 3, Consuming the schema from a Next.js frontend. Wires the backend to a Next.js 16 App Router application using Apollo Client. Covers RSC-based reads, Server Actions for writes, fragment composition, filter syntax on the client, and the create / update / inline-action flows for the mutations defined in this post.
Part 4, Users, permissions, and per-user content. The project in Parts 1 and 2 is intentionally single-user. Part 4 adds Strapi's users-permissions plugin, an owner relation on Note, cookie-stored JWTs for the Next.js frontend, and a two-layer authorization model: a resolver middleware that injects owner: { id: { $eq: me.id } } into read filters, and resolver policies on every write mutation that reject requests targeting someone else's notes. The custom queries, mutations, and computed fields from this post continue to work unchanged; they just run in the context of an authenticated user.
Citations