Strapi Integration
Read Strapi 5 content with typed server queries, serve the fixed image variants generated by its media library, and invalidate cached pages after an editor publishes.
Configure Strapi
pnpm add @farm.js/strapi @strapi/client// farm.config.ts
import { defineConfig } from "@farm.js/core";
import { strapi } from "@farm.js/strapi";
export default defineConfig({
integrations: {
cms: strapi(),
},
});With no options, the integration reads its configuration from the environment.
| Variable | Purpose |
|---|---|
STRAPI_API_URL | Strapi Content API base URL, including /api. |
STRAPI_MEDIA_URL | Optional public media origin. Defaults to the API URL's origin. |
STRAPI_API_TOKEN | Optional server-only API token for non-public content. |
STRAPI_WEBHOOK_SECRET | Required once webhook invalidation is configured. |
A missing API URL fails while the config loads, with a message naming STRAPI_API_URL.
Choose client ownership
The application can own the official Strapi client. Build it once, use it from server queries, and pass the same object to the integration so Farm does not construct a second client.
// src/lib/cms.server.ts
import {
createStrapiClient,
createStrapiCollection,
resolveStrapiConfig,
type StrapiDocument,
} from "@farm.js/strapi";
interface Article extends StrapiDocument {
title: string;
slug: string;
}
export const cms = createStrapiClient(resolveStrapiConfig({}));
export const articles = createStrapiCollection<Article>(cms, "articles");// farm.config.ts
integrations: {
cms: strapi({ instance: cms }),
}Pass any configured StrapiClient through instance for official client options the integration
does not expose.
Read content
Wrap collection reads in createServerQuery. Results are cached under the query key and can be
invalidated by that key later.
// src/lib/posts.ts
import { createServerQuery } from "@farm.js/core";
import { z } from "zod";
import { articles } from "./cms.server";
const article = z.object({
documentId: z.string(),
title: z.string(),
slug: z.string(),
});
export const postsQuery = createServerQuery({
output: z.array(article),
key: () => ["strapi", "articles"],
staleTime: "5m",
handler: () =>
articles.find({
fields: ["title", "slug"],
populate: ["cover"],
sort: ["publishedAt:desc"],
status: "published",
}),
});createStrapiCollection<T> accepts the official client's populate, fields, filters, sort,
pagination, locale, and status query options and unwraps the REST data envelope. Its generic
describes what the application expects, while the server query's output validates what permissions
and population actually returned.
Serve images
Strapi generates a fixed set of sizes at upload time. getStrapiImageProps selects the smallest
available image at least as wide as requested, falls back to the largest without inventing an
upscaled URL, and builds a native srcset from the variants present on that asset.
import { getStrapiImageProps, type StrapiMediaAsset } from "@farm.js/strapi";
export function Cover({ asset }: { asset: StrapiMediaAsset }) {
return (
<img
{...getStrapiImageProps(asset, {
mediaUrl: process.env.STRAPI_MEDIA_URL ?? new URL(process.env.STRAPI_API_URL!).origin,
width: 800,
sizes: "(max-width: 800px) 100vw, 800px",
})}
/>
);
}Absolute upload-provider URLs are preserved. Relative URLs are resolved against mediaUrl, which
can differ from the Content API host.
Invalidate on publish
Strapi can send a fixed header with each webhook. The integration verifies that shared secret in constant time before parsing the body, then asks the application which cache entries the event affects.
strapi({
instance: cms,
webhook: {
onChange(payload) {
if (payload.model !== "article") return;
const entry = payload.entry as { documentId?: string; slug?: string };
return {
keys: [
["strapi", "articles"],
...(entry.documentId ? [["strapi", "article", entry.documentId] as const] : []),
],
paths: ["/articles", ...(entry.slug ? [`/articles/${entry.slug}`] : [])],
};
},
},
});In Strapi, create a webhook under Settings > Webhooks:
- URL:
https://your-app.example/api/strapi/webhook - Events: the entry and media events your mapping handles
- Header name:
x-farm-webhook-secret - Header value: the value of
STRAPI_WEBHOOK_SECRET
Change webhook.secretHeader if the Strapi project uses another header name. The route answers
401 before parsing an unauthenticated body, 400 for malformed JSON, and 500 without reflecting
application errors or credentials.
Production notes
Expiry is the fallback. Strapi does not retry failed webhook deliveries. Give server queries a
time-based staleTime; use webhooks to refresh earlier, not as the only freshness mechanism.
Multiple instances. Farm's data cache lives in memory per process unless a distributed adapter
is configured. On a serverless platform the webhook reaches one instance. Configure an adapter such
as @farm.js/cache-redis when invalidation must reach every instance.
Tokens stay on the server. Keep STRAPI_API_TOKEN, createStrapiClient, and collection helpers
in server-only application modules. A draft query uses the same Content API token, so enable draft
selection only after application-owned preview authentication, never from an untrusted query
parameter.
Options
| Option | Default | Notes |
|---|---|---|
apiUrl | STRAPI_API_URL | Content API base URL, including /api. |
mediaUrl | API origin | Public origin for relative upload URLs. |
token | STRAPI_API_TOKEN | Server only. |
instance | Existing StrapiClient; skips API URL validation. | |
webhook.secret | STRAPI_WEBHOOK_SECRET | |
webhook.path | /api/strapi/webhook | |
webhook.secretHeader | x-farm-webhook-secret | Configure the same custom header in Strapi. |
webhook.onChange | Maps a payload to { keys?, paths? }. | |
log | Integration lifecycle logger. |
See Strapi's Preview documentation before exposing draft content. Basic Preview is available without Live Preview; the side-by-side editor and source maps are plan-dependent and remain experimental in Strapi.
Read Strapi 5 content with typed server queries, serve generated image variants, and invalidate cached pages after an editor publishes.