@fbritoferreira/strapi
v0.21.0
Published
TypeScript client for the Strapi 5 REST and GraphQL APIs: typed collections, single types, auth, uploads and custom routes, with types generated from your own schema.
Maintainers
Readme
@fbritoferreira/strapi
A TypeScript client for the Strapi 5 REST API. A root Strapi class wraps
collection types, single types, the users-permissions plugin (/api/users)
and the upload plugin (/api/upload); a StrapiClient shorthand covers a
single collection. Every method returns a [error, data, meta] tuple instead
of throwing. The strapi-client generate CLI command writes TypeScript
interfaces and the content-type registry from your Strapi schema.
What you get
- Tuples, not throws. Every method answers
[error, data, meta], so a failed request is a value you handle, not an exception you remember to catch. - Types generated from your schema.
strapi-client generatereads a Strapi project or a running instance and writes the interfaces plus a registry, sostrapi.collection("articles")is typed without a type argument. - Params checked against the route. Each method accepts only the query
params its Strapi route declares, and
fields/populateare told apart. - Results that match the request. Select two fields and the returned type has two fields; a relation appears only once something populates it.
- The rest of the API too. Auth (
/api/auth/*), users, uploads, custom and plugin routes typed from an OpenAPI document, and GraphQL.
Contents
Installation · Quick start · Clients · Authentication · Query parameters · Writing · Fetching every page · Streaming pages · i18n · Errors · Retries · Next.js and custom fetch · Typed registry · Generating types · One config for every source · Route types from OpenAPI · GraphQL · Recipes · Development
Installation
npm install @fbritoferreira/strapipnpm add @fbritoferreira/strapiyarn add @fbritoferreira/strapiRequires Node.js >= 20.3 (for AbortSignal.any, which merges your signal
with the client's timeout). Ships ESM and CommonJS builds with bundled type
declarations.
From JSR
The same package is published to JSR as TypeScript source, for Deno, Bun and npm-compatible projects:
deno add jsr:@fbritoferreira/strapinpx jsr add @fbritoferreira/strapipnpm dlx jsr add @fbritoferreira/strapibunx jsr add @fbritoferreira/strapiIn Deno you can also import it without installing:
import { Strapi } from "jsr:@fbritoferreira/strapi";
const strapi = new Strapi({ baseURL: "http://localhost:1337", defaultLocale: "en" });The JSR package exports the client library only. The strapi-client CLI
(see Generating types) is available from npm.
Quick start
import { Strapi } from "@fbritoferreira/strapi";
interface Article {
documentId: string;
title: string;
body: string;
}
const strapi = new Strapi({
baseURL: "http://localhost:1337",
defaultLocale: "en",
...(process.env.STRAPI_TOKEN && { token: process.env.STRAPI_TOKEN }),
});
const articles = strapi.collection<Article>("articles");
const [err, items, meta] = await articles.findMany({
params: { filters: { title: { $contains: "strapi" } }, pagination: { pageSize: 10 } },
});
if (err) throw new Error(`${err.name}: ${err.message}`);
console.log(items.length, "of", meta?.pagination?.total);
const [createErr, created] = await articles.create({ payload: { data: { title: "Hello", body: "..." } } });
const [, updated] = await articles.update({ documentId: created!.documentId, payload: { data: { title: "Hi" } }, params: { status: "published" } });Clients
new Strapi(config) exposes one sub-client per Strapi API surface:
| Client | Access | Methods |
| --- | --- | --- |
| Collection types | strapi.collection<T>("articles") | findMany, find, findFirst, count, create, update, delete, upsert |
| Single types | strapi.single<T>("homepage") | find, update, delete |
| Auth | strapi.auth | login, register, forgotPassword, resetPassword, changePassword, sendEmailConfirmation, refresh, logout |
| Users-permissions | strapi.users<T>() | findMany, find, me, count, create, update, delete |
| Upload | strapi.files | find, findOne, upload, update, delete |
| Generated routes | strapi.route("GET /upload/files") | any route in the StrapiRoutes registry |
| GraphQL | strapi.graphql(document) | one operation against /graphql |
delete returns the deleted document (or null when Strapi answers with an
empty body), so it is a read as much as a write.
collection and single accept a StrapiContentTypes/StrapiSingleTypes
registry key (see Typed registry below) or any string uid with an explicit
type argument. users and files work against /api/users and
/api/upload; they return plain bodies with numeric ids and no locale
handling, matching how those plugins actually respond.
StrapiClient<T> is a shorthand for new Strapi(config).collection<T>(uid).
It is a collection client only. It has no files, users() or single().
import { StrapiClient } from "@fbritoferreira/strapi";
const articles = new StrapiClient<Article>({
baseURL: "http://localhost:1337",
defaultLocale: "en",
uid: "articles",
});Authentication
strapi.auth covers the users-permissions routes at /api/auth/*:
const [err, session] = await strapi.auth.login({ identifier: "[email protected]", password: "…" });
if (err) throw new Error(err.message);
strapi.setToken(session.jwt); // every later request carries itsetToken(undefined) clears it again. The JWT is not adopted automatically:
one client instance is often shared, and silently rebinding its identity is
rarely what you want.
| Method | Route | Notes |
| --- | --- | --- |
| login | POST /api/auth/local | { jwt, refreshToken?, user } |
| register | POST /api/auth/local/register | jwt is absent when email confirmation is enabled |
| forgotPassword | POST /api/auth/forgot-password | { ok: true } |
| resetPassword | POST /api/auth/reset-password | completes the forgot-password flow |
| changePassword | POST /api/auth/change-password | needs the signed-in user's token |
| sendEmailConfirmation | POST /api/auth/send-email-confirmation | { email, sent } |
| refresh | POST /api/auth/refresh | rotates a refresh token |
| logout | POST /api/auth/logout | scope and deviceId narrow what is revoked |
refresh and logout exist only when the plugin runs with
jwtManagement: "refresh"; otherwise Strapi answers 404 and the error says so.
With an httpOnly refresh cookie, the token travels in the cookie and the
response carries no refreshToken.
strapi.users() covers /api/users — including me() — and takes the same
user type. A populated role is typed as StrapiRole; unpopulated it is the
role id.
The users-permissions and upload routes are not content-API routes, so their
params are narrower: findMany and files.find take fields, populate,
sort, pagination and filters; find, me and files.findOne take
fields and populate; count takes filters alone. status, locale and
_q are not part of those routes and are rejected.
Query parameters
Pass params: QueryParams<T> to filter, sort, select fields, set the
publication status, and paginate.
const [err, matching, meta] = await articles.findMany({
params: {
filters: { title: { $contains: "strapi" } },
populate: ["category", "author"],
fields: ["title", "body"],
sort: ["title:asc"], // keys of T, optionally `:asc`/`:desc`; relation paths like "author.name:asc" are anchored to a key of T
pagination: { pageSize: 10 },
status: "published",
},
});fields takes scalar fields; relations, components, media and dynamic zones go
in populate. Generated types carry a __populatable marker listing which
fields are which, so selecting one with the wrong param is a compile error:
articles.findMany({ params: { fields: ["cover"] } }); // error: cover is populatable
articles.findMany({ params: { populate: ["title"] } }); // error: title is scalarThe marker is type-level only — Strapi never returns it, and it is excluded
from filters, sort and create/update payloads. Hand-written types without a
marker keep accepting any key in both params.
The result follows the selection
Params passed inline also narrow what comes back, so the returned type is what Strapi actually sends:
const [, articles] = await strapi.collection("articles").findMany({
params: { fields: ["title", "slug"], populate: ["author"] },
});
// articles: { id: number; documentId: string; title: string; slug: string;
// author: Author | null }[]
const [, plain] = await strapi.collection("articles").findMany();
plain[0]?.author; // error: nothing populated it, so Strapi does not return itTwo rules behind that: Strapi selects [id, documentId, ...fields] when
fields is given, and returns a populatable field only when populate asks
for it — where it then stops being optional. populate: "*" populates every
first-level relation, component, media and dynamic zone.
Narrowing needs a generated type (the __populatable marker) and params
literal enough to read. Params held in a variable, or a hand-written type, give
the full document back as before:
const params: ListQueryParams<Article> = { fields: ["title"] };
const [, all] = await articles.findMany({ params }); // Article[], unchangedpagination accepts either page-based (page, pageSize) or offset-based
(start, limit) options; Strapi picks the mode from whichever fields are
present. status is Strapi 5's Draft & Publish filter ("draft" or
"published"). _q runs Strapi's full-text search.
Each method takes only the params its route accepts, mirroring the contracts Strapi declares for its core routes:
| Method | Params |
| --- | --- |
| findMany, findFirst, count | ListQueryParams<T> — the full read surface, including pagination, sort, filters and _q |
| find | FindQueryParams<T> — no pagination, no _q |
| create, update, upsert | WriteQueryParams<T> — fields and populate only; they shape the response, not which documents are written |
| delete | DeleteQueryParams<T> — fields, populate, filters; returns the deleted document, or null when Strapi sends an empty body |
| SingleTypeClient.find | FindQueryParams<T> |
| SingleTypeClient.update | WriteQueryParams<T> |
All of them keep the conditional params Strapi adds for localized and
Draft & Publish content types: locale, status, publicationFilter and the
deprecated hasPublishedVersion. publicationFilter takes one of Strapi's
publication cohorts — never-published, has-published-version, modified,
unmodified, never-published-document, has-published-version-document,
published-without-draft, published-with-draft — and Strapi answers a 400
for anything else.
Writing
Strapi takes relations and media by reference — a documentId, a numeric
id, or the connect/disconnect/set longhand — while components and
dynamic zones are written inline. Generated types carry a __relations marker
so the payload is checked the same way:
await articles.create({
payload: {
data: {
title: "Hello",
author: "author-document-id",
tags: ["tag-1", "tag-2"],
cover: { id: 7 },
seo: { metaTitle: "Hello" }, // a component: inline
},
},
});
await articles.update({
documentId,
payload: {
data: {
tags: {
connect: [{ documentId: "tag-3", position: { end: true } }],
disconnect: ["tag-1"],
},
},
},
});
await articles.create({ payload: { data: { author: { name: "Ada" } } } });
// error: a relation takes a reference, not the related documentA reference is a documentId, an id, or the longhand
{ documentId, locale?, status?, position? } / { id, position? }. To-many
fields take a list of them; to-one fields take one, or null to clear it.
position orders a connected relation: { before }, { after },
{ start: true } or { end: true }.
Types written by hand, with no marker, keep the previous DeepPartial<T>
payload.
Fetching every page
Pass all: true to fetch every page and concatenate the results, instead of
one page at a time.
const [err, all, meta] = await articles.findMany({
params: { pagination: { pageSize: 100 } },
all: true,
});Mode follows the pagination you pass: page/pageSize, or nothing, for
page mode; start/limit for offset mode. The client fetches the first page
to learn the total, then requests the rest in that same mode:
const [err, all] = await articles.findMany({
params: { pagination: { start: 0, limit: 100 } },
all: true,
});Remaining pages are fetched in parallel, bounded by concurrency (default 5).
Set it on the constructor:
const strapi = new Strapi({ baseURL: "http://localhost:1337", defaultLocale: "en", concurrency: 10 });Streaming pages
all: true concatenates every page in memory, which is fine for hundreds of
documents and wrong for hundreds of thousands. pages() hands each page over
as it arrives, and only asks for the next when you do:
for await (const [err, batch] of articles.pages({ params: { pagination: { pageSize: 100 } } })) {
if (err) throw new Error(err.message);
await writeRows(batch);
}Each iteration yields the same [error, data, meta] tuple as everything else,
and params narrows each page exactly as findMany does. Breaking out of the
loop stops the requests. An error ends the walk — there is no cursor to
continue from — as does an empty page, so a stale total cannot spin forever.
Both pagination modes work: pass page/pageSize or start/limit and the
walk continues in the mode you asked for, whichever the server answers in.
i18n
defaultLocale is required on both Strapi and StrapiClient; there is no
implicit "en" default, and the constructor throws a TypeError if it is
missing or empty.
create with locale set to a non-default locale searches for the base
document in defaultLocale using filters, creates it if it does not exist,
then adds the localization. filters is how you identify which
default-locale document the new localization belongs to; omitting filters
skips that lookup entirely and always creates a fresh default-locale
document before localizing it:
const [err, frArticle] = await articles.create({
payload: { data: { title: "Article en français", body: "..." } },
locale: "fr",
filters: { title: { $eq: "Existing Title" } },
});update and delete take documentId plus locale to target one
localization:
await articles.update({ documentId: frArticle!.documentId, payload: { data: { title: "Updated" } }, locale: "fr" });
// Deletes only the fr localization; the default-locale document and other
// localizations are untouched.
await articles.delete({ documentId: frArticle!.documentId, locale: "fr" });Errors
Every method returns [error, data, meta]. data and meta are null when
error is set.
export interface ServiceError {
message: string;
status?: number;
name?: string;
details?: unknown;
cause?: unknown;
}A validation error carries the field-level problems in details. Strapi shapes
that differently per error — a rejected query param reports { source, param },
for instance — so details stays unknown and validationIssues reads the
validation case safely:
import { validationIssues } from "@fbritoferreira/strapi";
const [err] = await articles.create({ payload: { data: {} } });
for (const issue of validationIssues(err)) {
form.setError(issue.path.join("."), issue.message); // ["seo", "metaTitle"] → "seo.metaTitle"
}It returns [] for any error without them, so there is nothing to guard first.
isValidationDetails is exported too, for narrowing details directly.
name is Strapi's own error name ("ValidationError", "NotFoundError",
etc.) when Strapi returned one, or one of "HTTPError", "TimeoutError",
"NetworkError" for failures the client classifies itself. details carries
Strapi's error.details, for example per-field validation errors.
const [err, created] = await articles.create({ payload: { data: { title: "" } } });
if (err) {
if (err.name === "ValidationError") {
console.error(err.details); // e.g. { errors: [{ path: ["title"], message: "title must be defined" }] }
}
throw new Error(`${err.name}: ${err.message}`);
}Retries
Off by default. Pass retry to repeat the failures worth repeating:
const strapi = new Strapi({
baseURL: "http://localhost:1337",
defaultLocale: "en",
retry: 3, // or { attempts: 3, delay: 300, maxDelay: 10_000 }
});What it repeats, and what it leaves alone:
- Statuses
408,429,500,502,503,504by default — the ones a second attempt can fix. A400or404is returned as it is. - Methods: only the idempotent ones (
GET,HEAD,OPTIONS). Repeating aPOSTcan create a second document, because the first may have been applied before the response was lost. Opt in per method withmethods: ["POST"]when you know the endpoint tolerates it. - Network failures, where no response arrived at all. Turn off with
network: false.
Retry-After is honoured, in seconds or as an HTTP date, capped at maxDelay.
Otherwise the wait doubles each attempt from delay, capped the same way. Set
jitter: true to spread the waits when many clients retry at once.
The timeout applies per attempt rather than to the whole sequence, and an
aborted signal stops the retrying — you asked for the request to stop, not to be
repeated. onRetry reports each wait:
retry: { attempts: 3, onRetry: ({ attempt, delay, status }) => log.warn({ attempt, delay, status }) }Next.js and custom fetch
Pass init on any call to merge extra RequestInit fields, including
Next.js's fetch extensions, into that request:
const [err, cached] = await articles.findMany({
params: { populate: "*" },
init: { next: { revalidate: 60, tags: ["articles"] } },
});The constructor also accepts headers, a custom fetch implementation, and
timeout (milliseconds, default 10_000):
const strapi = new Strapi({
baseURL: "http://localhost:1337",
defaultLocale: "en",
headers: { "X-Custom": "1" },
fetch: myFetch,
timeout: 5000,
});Typed registry
Augment StrapiContentTypes and StrapiSingleTypes so collection() and
single() infer T from the uid, without an explicit type argument:
declare module "@fbritoferreira/strapi" {
interface StrapiContentTypes {
articles: Article;
}
interface StrapiSingleTypes {
homepage: Homepage;
}
}
strapi.collection("articles"); // CollectionClient<Article>
strapi.single("homepage"); // SingleTypeClient<Homepage>Once the registry is augmented, a uid it does not declare is a compile error,
which catches typos like strapi.collection("aritcles"). Both escape hatches
stay open: an explicit type argument overrides the registry and accepts any
uid (strapi.collection<Article>("custom-route")), and adding the uid to the
augmentation makes it first class. While the registry is empty — no generated
file imported — any uid is accepted and falls back to CollectionClient<object>
/ SingleTypeClient<object>.
StrapiClient's uid is constrained the same way; for a uid outside the
registry use new Strapi(config).collection<T>(uid).
See Generating types below for a command that emits this augmentation from your Strapi schema.
Generating types
strapi-client generate writes the interfaces and the registry augmentation for you.
It takes exactly one source: --dir or --url for content-type schemas (this
section), --openapi for route types, or
--graphql for GraphQL schema types. Each writes its own file, and
they are meant to be used side by side.
# From a Strapi project checked out next to your app
npx @fbritoferreira/strapi generate --dir ../my-strapi -o src/strapi-types.ts
# From a running instance (admin user credentials, not an API token)
[email protected] STRAPI_ADMIN_PASSWORD=... \
npx @fbritoferreira/strapi generate --url https://cms.example.com -o src/strapi-types.ts
# In CI: fail when the committed file is stale
npx @fbritoferreira/strapi generate --dir ../my-strapi -o src/strapi-types.ts --checkThe installed binary is named strapi-client, so npx @fbritoferreira/strapi generate and strapi-client generate from a local install run the same command.
Import the generated file once anywhere in your app (import "./strapi-types";) and strapi.collection("articles") returns CollectionClient<Article>.
What is generated:
- One
interfaceperapi::content type, extendingStrapiDocument; localized types get a requiredlocale. - One
interfaceper component, withid: number. - Relations, media, components and dynamic zones are optional fields (they appear only when populated).
mediaisStrapiMedia | nullorStrapiMedia[]; relations toplugin::users-permissions.userareStrapiUser. - Dynamic zones are
Array<(BlocksHero & { __component: "blocks.hero" }) | ...>. enumerationbecomes a union of string literals;jsonisunknown;bigintegerisstring.- A
__relationsmarker per type, listing the fields written by reference — relations and media, but not components or dynamic zones. - A
__populatablemarker per type, listing the fieldspopulateaccepts. It exists only in the type system — Strapi never returns it — and is what letsfields,sortandpopulatebe told apart and results be narrowed. privateattributes are skipped. Plugin content types are skipped unless--include-pluginsis passed.--include-pluginsregisters plugin content types under theirpluralNameeven when the plugin does not expose a matching/api/<pluralName>route.
The --url source calls POST /admin/login and the Content-Type Builder routes, which require an admin user with the plugin::content-type-builder.read permission. Strapi does not accept API tokens on admin routes.
One config for every source
Three sources means three invocations. --config runs them together, from a
TypeScript file that type-checks itself:
// strapi-codegen.config.ts
import { generateConfig } from "@fbritoferreira/strapi";
export default generateConfig({
types: {
url: "https://cms.example.com",
password: process.env.STRAPI_ADMIN_PASSWORD,
output: "src/strapi-types.ts",
},
routes: {
openapi: "https://cms.example.com/documentation/v1.0.0",
output: "src/strapi-routes.ts",
},
graphql: {
url: "https://cms.example.com/graphql",
output: "src/strapi-graphql.ts",
},
});npx @fbritoferreira/strapi generate --config # all of it
npx @fbritoferreira/strapi generate --config --check # CI: fail on a stale filegenerateConfig is an identity function — it exists so the file is checked as
you write it. Naming both dir and url under types, or leaving a section
without its source, is a compile error; being a .ts file, it can also read
process.env directly rather than inventing an interpolation syntax.
Every section is optional and they run in order, each writing its own file. A
failing section does not stop the others: the command reports 2 of 3
generated and exits 1, so one broken source cannot hide the rest.
Configs are looked up as strapi-codegen.config.ts, .mts, .js, .mjs,
then .json, or pass a path: --config config/strapi.ts. A .ts config needs
a Node that strips types (22.6 or newer); on anything older the command says so
and a .mjs or .json config works instead.
Credentials fall back to the same environment variables as the flags:
STRAPI_ADMIN_EMAIL, STRAPI_ADMIN_PASSWORD, STRAPI_TOKEN,
STRAPI_DOCS_PASSWORD.
Route types from OpenAPI
Content-type schemas describe documents, not routes. For custom routes and the
plugin endpoints (/auth/local, /users, /upload/files), generate a
StrapiRoutes registry from an OpenAPI document instead:
# Strapi 5 writes one with its own CLI (experimental)
cd ../my-strapi && npx strapi openapi generate --output ../my-app/spec.json
# then, in your app
npx @fbritoferreira/strapi generate --openapi spec.json -o src/strapi-routes.ts
# a URL works too, e.g. the documentation plugin's spec
npx @fbritoferreira/strapi generate \
--openapi https://cms.example.com/documentation/v1.0.0/full_documentation.json
# or the documentation plugin's own page, which inlines the spec rather than serving it
npx @fbritoferreira/strapi generate \
--openapi https://cms.example.com/documentation/v1.0.0 -o src/strapi-routes.tsThe source can be a JSON document or a Swagger UI page: recent versions of
@strapi/plugin-documentation render the spec inline with
SwaggerUIBundle({ spec: … }) and serve no JSON endpoint at all, so the loader
reads it out of the page. --token (or STRAPI_TOKEN) authenticates either.
When the plugin runs with restrictedAccess, the page is behind a password
rather than a token — it redirects to /documentation/login and keeps a
session cookie. Pass --password (or STRAPI_DOCS_PASSWORD) and the loader
signs in first and reuses that cookie:
npx @fbritoferreira/strapi generate \
--openapi https://cms.example.com/documentation/v1.0.0 --password "…" -o src/strapi-routes.tsWithout it, a restricted page reports what to do rather than failing on the login form's HTML.
The output augments StrapiRoutes with one entry per route, keyed
"<METHOD> <path>", and strapi.route() calls them:
import "./strapi-routes";
const [err, session] = await strapi.route("POST /auth/local", {
body: { identifier: "[email protected]", password: "…" },
});
const [, file] = await strapi.route("GET /upload/files/{id}", { params: { id: 7 } });Path params are substituted into the path, query is serialized like collection
params, and the body is returned exactly as Strapi sends it — these routes have
no data/meta envelope, so route() does not unwrap one.
Use OpenAPI for routes, not for documents. Strapi's generated spec is lossier
than its schemas: a dynamic zone arrives as {"type":"array","items":{}} with the
component union gone, responses carry no meta, and documentId is described as
a UUID. Keep generating document types from --dir or --url; the two outputs
are separate files and work side by side.
Routes whose path is a raw regex (Strapi emits /connect/(.*) for provider
callbacks) are skipped: they cannot be called by name.
GraphQL
Strapi serves GraphQL at /graphql — at the origin, not under /api — when
@strapi/plugin-graphql is installed. strapi.graphql() runs one operation
there, with the same bearer token and [error, data] tuple as the REST clients:
const [err, data] = await strapi.graphql<{ articles: Article[] }>(
`query Articles($locale: I18NLocaleCode) {
articles(locale: $locale) { documentId title }
}`,
{ variables: { locale: "fr" } }
);GraphQL errors come back as the error tuple, with the whole errors array in
details and the single error's extensions.code as name. A 404 — the plugin
is not installed — is reported as such. Pass graphqlEndpoint to new Strapi()
when the plugin's endpoint option is configured; strapi.graphqlUrl shows the
resolved URL.
Queries without writing GraphQL
--graphql also registers every root field with its arguments and result, so
the common operations need no document at all:
import { strapiGraphqlArgs } from "./strapi-graphql";
const strapi = new Strapi({ baseURL, defaultLocale: "en", graphqlArgs: strapiGraphqlArgs });
const [err, articles] = await strapi.query("articles", {
args: { locale: "fr", pagination: { limit: 10 } },
select: { documentId: true, title: true, author: { name: true } },
});
// articles: { documentId: string; title: string; author: { name: string } | null }[]The client builds the document and the variables:
query Articles($locale: I18NLocaleCode, $pagination: PaginationArg) {
articles(locale: $locale, pagination: $pagination) { documentId title author { name } }
}Arguments travel as variables rather than inline literals, so the server parses
them as JSON — a string that looks like an enum stays a string, and nothing has
to be escaped by hand. Their GraphQL types come from strapiGraphqlArgs, which
is why the client needs it; passing an argument the field does not declare is
refused before anything is sent.
select is checked against the schema and narrows the result, the same way
fields and populate narrow a REST read: ask for two fields and the type has
two fields, descend into a relation and it keeps its own nullability. Mutations
work identically through strapi.mutate().
What this does not cover: fragments, aliases, directives, unions and multiple operations in one document. Those are what the typed documents below are for.
Typed documents
graphql() also takes a document that carries its own types — a
TypedDocumentNode, or the TypedDocumentString graphql-codegen emits with
documentMode: "string". Both type arguments are then inferred, variables is
required exactly when the document declares a required one, and the selection
set itself is typed, which a raw string cannot be:
import { ArticlesDocument } from "./gql/graphql";
const [err, data] = await strapi.graphql(ArticlesDocument, { variables: { locale: "fr" } });
// data: { articles: { documentId: string; title: string }[] }Point graphql-codegen at your Strapi instance to produce those documents:
// codegen.ts
import type { CodegenConfig } from "@graphql-codegen/cli";
const config: CodegenConfig = {
schema: "http://localhost:1337/graphql",
documents: ["src/**/*.{ts,tsx}"],
generates: {
"./src/gql/": { preset: "client", config: { documentMode: "string" } },
},
};
export default config;documentMode: "string" keeps the query as text, so nothing has to parse an AST
at runtime. The default AST form works too — its source text is read from
loc. A document with neither (an AST built without location info) comes back
as an error tuple naming the fix rather than sending an empty query.
No dependency is added for this: TypedDocument<TData, TVariables> matches the
__apiType marker both forms carry.
To type the operations without codegen, generate the schema:
npx @fbritoferreira/strapi generate --graphql http://localhost:1337/graphql -o src/strapi-graphql.tsThat introspects the endpoint and writes one exported type per object, interface, enum, input object and union — so query results and variables can be annotated with the schema's own names:
import type { Article, ArticleFiltersInput } from "./strapi-graphql";
const [err, data] = await strapi.graphql<{ articles: Article[] }, { filters: ArticleFiltersInput }>(
"query Articles($filters: ArticleFiltersInput) { articles(filters: $filters) { documentId title } }",
{ variables: { filters: { title: { eq: "Hello" } } } }
);Those types describe the schema, not a selection: the generated Article has
every field, not the ones a given query selected. Typed documents above cover
that case. Introspection has to be reachable — Apollo
disables it when NODE_ENV=production, so generate against a development
instance.
Recipes
Every snippet below is compiled as part of the test suite
(src/cli/__fixtures__/readme-recipes.ts), against the same Article type the
generator would emit:
interface Article extends StrapiDocument {
readonly __populatable?: "cover" | "author";
title: string;
slug: string;
body: string;
cover?: StrapiMedia | null;
author?: { documentId: string; name: string } | null;
}Search, then walk every page
const [err, all] = await articles.findMany({
params: { _q: term, sort: ["publishedAt:desc"], pagination: { pageSize: 100 } },
all: true,
});A list view only needs a few columns
const [err, rows] = await articles.findMany({
params: { fields: ["title", "slug"], populate: ["cover"], pagination: { pageSize: 20 } },
});
if (err) throw new Error(err.message);
rows.map((row) => ({ title: row.title, href: `/blog/${row.slug}`, image: row.cover?.url }));
// row.body is a compile error here: it was not selectedUpsert by slug
const [err, article] = await articles.upsert({
payload: { data: { slug, title, body: "…" } },
filters: { slug: { $eq: slug } },
params: { status: "published" },
});Upload a file and attach it in one call
const [err, uploaded] = await strapi.files.upload({
files: file,
ref: "api::article.article",
refId: documentId,
field: "cover",
});Sign in, keep the session, refresh it later
const [err, session] = await strapi.auth.login({ identifier, password });
if (err) throw new Error(err.message);
strapi.setToken(session.jwt);
if (session.refreshToken !== undefined) {
const [refreshErr, refreshed] = await strapi.auth.refresh({ refreshToken: session.refreshToken });
if (!refreshErr) strapi.setToken(refreshed.jwt);
}Next.js: cache a read and revalidate it by tag
const [err, data] = await articles.findMany({
params: { fields: ["title", "slug"] },
init: { next: { revalidate: 3600, tags: ["articles"] } },
});One localization at a time
await articles.update({ documentId, payload: { data: { title } }, locale: "fr" });Migrating from 0.4
id: numberaddressing is gone.find,updateanddeletenow takedocumentId: string; Strapi 5 routes acceptdocumentIdonly.findwith no id (the old "find all" call) is removed. UsefindFirst.- Every method now returns
[error, data, meta]instead of[error, data].meta.paginationcarriestotalandpageCount. Existing two-element destructuring (const [err, data] = ...) still works; the third element is ignored. Tno longer has to declareid.ServiceErrorgainedname,detailsandcause. Strapi'serrorbody, validation details included, is copied into it.defaultLocaleis now required. There is no"en"default.StrapiClientstays as a shorthand for a single collection and now takesdefaultLocale, but it is collection-only: it has nofiles,users()orsingle(). Usenew Strapi(...)when you need those.
Development
- Clone and install:
git clone <repo> && pnpm install(Node.js 24, see.nvmrc) - Run tests:
pnpm test(Vitest),pnpm test:coveragefor coverage — thresholds are 100% on statements, branches, functions and lines.pnpm smokeexercises the built package the way CI does on the oldest supported Node, where Vitest itself cannot run - Lint and typecheck:
pnpm lint && pnpm typecheck - Build:
pnpm build(outputs ESM, CJS and bundled.d.tstodist/; also builds thegenerateCLI todist/cli.mjs, used bybin/strapi-client.mjs) - Add a changeset for user-facing changes:
pnpm changeset - After changing
src/cli/emit.ts, refresh the fixture snapshot:UPDATE_SNAPSHOT=1 pnpm vitest run src/test/cli/emit.spec.ts - README examples live in
src/cli/__fixtures__/readme-recipes.tsand are type-checked bypnpm typecheck; update both together - Check the JSR publish (slow types, included files):
pnpm jsr:check
Uses Vite for building and Vitest for testing. Releases are cut by the
Release GitHub workflow from main via Changesets: it publishes to npm
(Trusted Publishing), creates the GitHub release, then publishes the same
version to JSR from source (jsr.json, OIDC provenance). The workflow keeps
jsr.json's version in sync with package.json; do not bump it by hand.
License
Distributed under the MIT License. See LICENCE.md for more information.
