New:Microsoft Teams Notifications Are Now Available in Socket.Learn more →
Get Started

@fbritoferreira/strapi

Package Overview
Dependencies
Maintainers
1
Versions
30
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@fbritoferreira/strapi

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.

latest
Source
npmnpm
Version
0.24.0
Version published
Weekly downloads
3.6K
2326.53%
Maintainers
1
Weekly downloads
 
Created
Source

@fbritoferreira/strapi

npm version License: MIT npm downloads JSR JSR Score Documentation

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.

The guide is at https://fbritoferreira.github.io/strapi/. Upgrading from 0.4: MIGRATION.md.

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 generate reads a Strapi project or a running instance and writes the interfaces plus a registry, so strapi.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/populate are 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

Start: Installation · Quick start · Clients

REST: Single types · Query parameters · Writing · Fetching every page · Streaming pages · i18n

Plugins: Authentication · Users · Uploads

Transport: Errors · Retries · Next.js and custom fetch

Codegen: Typed registry · Generating types · One config for every source · Route types from OpenAPI · GraphQL

Recipes · Development

Installation

npm install @fbritoferreira/strapi
pnpm add @fbritoferreira/strapi
yarn add @fbritoferreira/strapi

Requires 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/strapi
npx jsr add @fbritoferreira/strapi
pnpm dlx jsr add @fbritoferreira/strapi
bunx jsr add @fbritoferreira/strapi

In 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:

ClientAccessMethods
Collection typesstrapi.collection<T>("articles")findMany, find, findFirst, count, create, update, publish, delete, upsert
Single typesstrapi.single<T>("homepage")find, update, delete
Authstrapi.authlogin, register, forgotPassword, resetPassword, changePassword, sendEmailConfirmation, refresh, logout
Users-permissionsstrapi.users<T>()findMany, find, me, count, create, update, delete
Uploadstrapi.filesfind, findOne, upload, update, delete
Generated routesstrapi.route("GET /upload/files")any route in the StrapiRoutes registry
GraphQLstrapi.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",
});

Single types

strapi.single("homepage") is one document per locale, so there is no list and no documentId. find returns NotFoundError when the single type has no document yet. update creates it on the first call and updates it afterwards. delete returns the deleted document, or null when the body is empty; locale deletes only that localization.

interface Homepage {
	title: string;
}

const homepage = strapi.single<Homepage>("homepage");
const [err, page] = await homepage.find({ params: { populate: "*" } });
const [, saved] = await homepage.update({ payload: { data: { title: "Welcome" } }, locale: "fr" });
await homepage.delete({ locale: "fr" });

find takes the same params as a collection find (no pagination, no _q). update and delete take fields and populate, which shape the response. publish() is update with { data: {} } and status=published: Strapi answers 400 if data is omitted, and a PUT without status publishes. Selection narrowing works the same way as on collections.

Authentication

strapi.auth covers the users-permissions routes at /api/auth/*:

const [err, session] = await strapi.auth.login({ identifier: "me@example.com", password: "…" });
if (err) throw new Error(err.message);

strapi.setToken(session.jwt); // every later request carries it

setToken(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.

MethodRouteNotes
loginPOST /api/auth/local{ jwt, refreshToken?, user }
registerPOST /api/auth/local/registerjwt is absent when email confirmation is enabled
forgotPasswordPOST /api/auth/forgot-password{ ok: true }
resetPasswordPOST /api/auth/reset-passwordcompletes the forgot-password flow
changePasswordPOST /api/auth/change-passwordneeds the signed-in user's token
sendEmailConfirmationPOST /api/auth/send-email-confirmation{ email, sent }
refreshPOST /api/auth/refreshrotates a refresh token
logoutPOST /api/auth/logoutscope 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.

A 401 is not retried unless you opt in. refreshOnUnauthorized rotates the refresh token, adopts the new JWT, and retries that request once. Concurrent 401s share one rotation. It does nothing when no bearer token is set, unless cookie: true (httpOnly refresh cookie, sent with credentials: "include").

let session = { jwt: "", refreshToken: "" };

const strapi = new Strapi({
	baseURL: "http://localhost:1337",
	defaultLocale: "en",
	token: session.jwt,
	refreshOnUnauthorized: {
		token: () => session.refreshToken,
		onRefresh: (next) => {
			session = { jwt: next.jwt, refreshToken: next.refreshToken ?? session.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.

Users

strapi.users() talks to /api/users. The body is the user or an array, no data wrapper, and meta is null. Users are addressed by numeric id, not documentId. create and update send data as the raw JSON body, not { data }.

MethodRouteParams
findManyGET /api/usersfields, populate, sort, pagination, filters
findGET /api/users/<id>fields, populate
meGET /api/users/mefields, populate. The user the configured token belongs to.
countGET /api/users/countfilters. The body is a number, not pagination meta.
createPOST /api/usersraw data object
updatePUT /api/users/<id>id, raw data
deleteDELETE /api/users/<id>returns the deleted user
const users = strapi.users();
const [err, me] = await users.me({ params: { populate: ["role"] } });
const [, total] = await users.count({ params: { filters: { confirmed: { $eq: true } } } });

A populated role is StrapiRole; unpopulated it is the role id. Pass a type argument when the user is not StrapiUser: strapi.users<Member>(). find, me, create, update and delete return NotFoundError when the body is empty. count returns 0 when the body is not a number.

Uploads

strapi.files is /api/upload. Same shape as users: plain objects, numeric id, no data wrapper.

MethodRouteNotes
findGET /api/upload/filesList params as above. Current Strapi 5 ignores pagination here and returns every file.
findPageGET /api/upload/files/pageThe same params, one page, with meta.pagination.
findOneGET /api/upload/files/<id>fields, populate
uploadPOST /api/uploadmultipart/form-data. One StrapiMedia per file.
updatePOST /api/upload?id=<id>Metadata only (name, alternativeText, caption). Does not re-upload.
deleteDELETE /api/upload/files/<id>Returns the deleted media entry.
const [err, uploaded] = await strapi.files.upload({
	files: file,
	fileName: "cover.png", // only needed when `files` is a Blob, not a File
	fileInfo: { alternativeText: "Cover" },
	ref: "api::article.article",
	refId: documentId, // forwarded as-is; string or number
	field: "cover",
});

await strapi.files.update({ id: 7, fileInfo: { caption: "Hero" } });
await strapi.files.delete({ id: 7 });

A File keeps its name. A plain Blob is named file-0 unless fileName is set. fileInfo may be one object or an array in the same order as files. StrapiMedia is the upload plugin's file: url, mime, size, formats (generated sizes, or null), plus the StrapiDocument fields.

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 scalar

The 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.

Populating with options

In the object form of populate, each field takes true, "*", or the same options a top-level read takes, typed against the document behind it: fields, populate, filters, sort and count. A dynamic zone takes on instead, keyed by component name:

articles.findMany({
	params: {
		populate: {
			author: { fields: ["name"], populate: { avatar: { fields: ["url"] } } },
			categories: { filters: { slug: { $ne: "hidden" } }, sort: ["name:asc"] },
			comments: { count: true }, // answers { count: number } instead of the documents
			blocks: { on: { "blocks.hero": { populate: ["image"] }, "blocks.quote": true } },
		},
	},
});

Filtering on relations

A relation or component filters on the related document's fields, with the same operators and $and/$or/$not at every depth, id and documentId included:

articles.findMany({
	params: {
		filters: {
			author: { documentId: { $eq: "abc" } },
			createdBy: { id: { $in: [1, 2] } }, // a relation to plugin::users-permissions.user
			categories: { parent: { slug: { $eq: "news" } } },
			$or: [{ views: { $null: true } }, { author: { name: { $startsWith: "A" } } }],
		},
	},
});

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 it

Two 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.

The same rules apply inside a populate map entry with options, at every depth: populate: { author: { fields: ["name"] } } gives author: { id; documentId; name } | null, and a to-many relation is narrowed element by element. count: true makes the field { count: number }. true, "*" and dynamic zones leave the related document whole.

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[], unchanged

pagination 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.

A bare filter value is $eq. The other operators are $eqi, $ne, $nei, $lt, $lte, $gt, $gte, $in, $notIn, $contains, $notContains, $containsi, $notContainsi, $startsWith, $startsWithi, $endsWith, $endsWithi, $between, $null, $notNull, $and, $or and $not. $null and $notNull take a boolean. $in, $notIn and $between take an array. Query strings are serialized with qs in bracket/index array format.

Each method takes only the params its route accepts, mirroring the contracts Strapi declares for its core routes:

MethodParams
findMany, findFirst, countListQueryParams<T>: the full read surface, including pagination, sort, filters and _q
findFindQueryParams<T>: no pagination, no _q
create, update, upsertWriteQueryParams<T>: fields and populate only; they shape the response, not which documents are written
deleteDeleteQueryParams<T>: fields, populate, filters; returns the deleted document, or null when Strapi sends an empty body
SingleTypeClient.findFindQueryParams<T>
SingleTypeClient.updateWriteQueryParams<T>

findFirst returns the first match, or null. It forces a page size of 1 (limit: 1 when you passed offset pagination, otherwise pageSize: 1). count does that same one-row read and returns meta.pagination.total, or the page length when the response has no pagination meta.

upsert updates the first document matching filters, or creates one. Without filters that first document is whatever the collection returns first, so pass a filter that identifies the row.

publish({ documentId }) sends PUT with { data: {} } and status=published. Omitting data is a 400, and a write without status publishes, so this is the call that publishes a draft without changing it. Single types have the same method, without a documentId.

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. 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 document

A 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, because there is no cursor to continue from. An empty page ends it too, 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, 504 by default, the ones a second attempt can fix. A 400 or 404 is returned as it is.
  • Methods: only the idempotent ones (GET, HEAD, OPTIONS). Repeating a POST can create a second document, because the first may have been applied before the response was lost. Opt in per method with methods: ["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, per attempt). baseURL may be the origin or already end in /api; a missing /api is appended, and a trailing slash is stripped. strapi.http.request(path, init) is the same transport for an endpoint this client does not wrap. init.signal is combined with the timeout via AbortSignal.any; an abort you requested is not retried.

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. Add --watch to keep regenerating as the schema changes (watch mode).

# 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)
STRAPI_ADMIN_EMAIL=me@example.com 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 --check

The first line of the generated file records its source, with local paths relative to the output file's directory, e.g. // Generated by @fbritoferreira/strapi generate from dir ../../my-strapi. Do not edit. There is no timestamp, so regenerating an unchanged schema leaves the file as it was and the same checkout produces the same file on every machine. --check ignores that line.

The 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 interface per api:: content type, extending StrapiDocument; localized types get a required locale.
  • One interface per component, with id: number.
  • Relations, media, components and dynamic zones are optional fields (they appear only when populated). media is StrapiMedia | null or StrapiMedia[]; relations to plugin::users-permissions.user are StrapiUser.
  • Dynamic zones are Array<(BlocksHero & { __component: "blocks.hero" }) | ...>.
  • enumeration becomes a union of string literals; json is unknown; biginteger is string.
  • A __relations marker per type, listing the fields written by reference: relations and media, but not components or dynamic zones.
  • A __populatable marker per type, listing the fields populate accepts. It exists only in the type system. Strapi never returns it, and it is what lets fields, sort and populate be told apart and results be narrowed.
  • private attributes are skipped. Plugin content types are skipped unless --include-plugins is passed.
  • --include-plugins registers plugin content types under their pluralName even 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 file

generateConfig 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.

Watch mode

--watch generates everything once, then keeps each file up to date while you edit schemas:

npx @fbritoferreira/strapi generate --config --watch
npx @fbritoferreira/strapi generate --config --watch --interval 5000
npx @fbritoferreira/strapi generate --dir ../my-strapi -o src/strapi-types.ts --watch
types: watching ../my-strapi/src
graphql: polling http://localhost:1337/graphql every 2s
types: wrote src/strapi-types.ts (12 types)
graphql: waiting for http://localhost:1337/graphql (connection refused)
config: watching strapi-codegen.config.ts
types: regenerated src/strapi-types.ts (13 types)
graphql: regenerated src/strapi-graphql.ts (48 types)

How each source is watched:

SourceTrigger
types.dirFile events under the project's src/api/*/content-types/ and src/components/*/*.json, debounced for 200ms because Strapi saves several files at once
routes.openapi as a local fileFile events for that file
types.url, graphql.url, routes.openapi as a URLPolling every --interval milliseconds, or watch.interval in the config (default 2000)
The config fileFile events; the config is reloaded and everything it declares is re-planned

A file is written only when its contents change, so an edit that does not change the generated types leaves the file, and anything watching it, alone. When a running instance goes away (Strapi restarts after a Content-Type Builder save), the section prints one waiting for line and picks up again once the instance answers. A section that fails prints its error once until the message changes. A config that no longer loads is reported, and the previous one keeps running. --url sources keep their admin session between polls, since Strapi allows five admin logins per five minutes by default.

export default generateConfig({
	types: { dir: "../my-strapi", output: "src/strapi-types.ts" },
	graphql: { url: "http://localhost:1337/graphql", output: "src/strapi-graphql.ts" },
	watch: { interval: 5000 },
});

--watch works for a single source too, and cannot be combined with --check. Stop it with Ctrl-C. Directory watching uses fs.watch with recursive, which Node supports on macOS, Windows and Linux; if a directory cannot be watched the section falls back to polling. Changes to modules that a .ts or .mjs config imports are not picked up until a restart.

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.ts

The 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.ts

Without 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: "me@example.com", 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 rather than 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. query and mutate return a TypeError naming the field when it was not passed. 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.ts

That 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 selected

Upsert 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" });

Development

  • Clone and install: git clone <repo> && pnpm install (Node.js 24, see .nvmrc)
  • Run tests: pnpm test (Vitest), pnpm test:coverage for coverage. Thresholds are 100% on statements, branches, functions and lines. pnpm smoke exercises 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.ts to dist/; also builds the generate CLI to dist/cli.mjs, used by bin/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.ts and are type-checked by pnpm typecheck; update both together
  • Check the JSR publish (slow types, included files): pnpm jsr:check
  • Docs site: pnpm docs:dev (VitePress). pnpm docs:build is what CI runs before GitHub Pages publishes https://fbritoferreira.github.io/strapi/

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.

Keywords

strapi

FAQs

Package last updated on 27 Sep 2026

Related posts