Farm.js

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.

VariablePurpose
STRAPI_API_URLStrapi Content API base URL, including /api.
STRAPI_MEDIA_URLOptional public media origin. Defaults to the API URL's origin.
STRAPI_API_TOKENOptional server-only API token for non-public content.
STRAPI_WEBHOOK_SECRETRequired 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

OptionDefaultNotes
apiUrlSTRAPI_API_URLContent API base URL, including /api.
mediaUrlAPI originPublic origin for relative upload URLs.
tokenSTRAPI_API_TOKENServer only.
instanceExisting StrapiClient; skips API URL validation.
webhook.secretSTRAPI_WEBHOOK_SECRET
webhook.path/api/strapi/webhook
webhook.secretHeaderx-farm-webhook-secretConfigure the same custom header in Strapi.
webhook.onChangeMaps a payload to { keys?, paths? }.
logIntegration 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.