CMS
Marketing content — rich pages at /{slug}, the blog, the homepage blocks,
header/footer globals, and category banners — is powered by a CMS provider
selected at runtime from the CMS_PROVIDER env var.
CMS is available on Next and Vue. The Next boilerplate ships a CMS engine in
lib/cms (Strapi, Prepr, Contentful); the Vue boilerplate ships one in
src/lib/cms (Strapi, Prepr). The Nuxt boilerplate has no CMS subsystem, so
--cms=<x> on a Nuxt scaffold prints a notice and proceeds with no CMS.
There is no adapter package to install. Every provider already ships in the boilerplate's
lib/cms/providers/(Vue:src/lib/cms/providers/). Selecting a CMS is just settingCMS_PROVIDER(the scaffolder pre-sets it) plus the provider's credentials. No code to wire, nothing tonpm install.
Built-in providers
| Provider | What it is |
|---|---|
strapi | Self-hosted Strapi v5 REST provider (Next, Vue). The scaffold puts the boilerplate's Strapi app, with its content types and seed script, in my-shop/cms/. Full contract — pages (typed blocks), articles, category banners, a typed global, static-slug enumeration, image-URL resolution. Configured with STRAPI_API_URL (+ optional STRAPI_API_TOKEN). |
prepr | Hosted Prepr GraphQL provider (Next, Vue). Same full contract. Introspects the Prepr environment's schema at runtime and builds its queries dynamically, so it adapts to rich vs simple content models. Authenticates with a GraphQL token embedded in the URL (PREPR_ACCESS_TOKEN) — no separate CMS URL. |
contentful | Hosted Contentful GraphQL provider (Next only). Same full contract. Reads via the Content Delivery API (or the Preview API for drafts); Rich Text fields are rendered to HTML. Authenticates with a Space ID + Delivery API token (CONTENTFUL_SPACE_ID + CONTENTFUL_CDA_TOKEN). SaaS-only — there is no self-hosted Contentful; the free tier suffices for a shop's marketing content. |
none | No CMS (default). Marketing slugs return 404 and the homepage falls back to its built-in static structure. |
All providers implement the same CmsProvider contract (pages with typed
blocks, blog articles, category banners, a typed global, static-slug
enumeration, and image-URL resolution), so the shop's components are identical
regardless of which backend is selected — only the CMS_PROVIDER env var and
its credentials change.
How a scaffolded shop wires CMS
create-propeller-shop --cms=<provider> does three things — and none of them
install a package or touch the shop's code:
- Sets
propeller.json -> cms.adapterto'strapi'/'prepr'/'contentful'/null. - In the shop's env example (
.env.local.exampleon Next,.env.exampleon Vue), flipsCMS_PROVIDER=none→CMS_PROVIDER=<provider>(Vue: plus theVITE_CMS_PROVIDERmirror) and uncomments that provider's credential lines so you can see exactly what to fill in. - Fills
my-shop/cms/: forstrapi, the boilerplate's Strapi app; otherwise a README with the connect steps.
Older shops may still carry the removed cms adapter in propeller.json;
doctor flags it.
To finish: copy .env.local.example → .env.local and fill in the provider
credentials. To switch provider later, change CMS_PROVIDER (and keep
propeller.json -> cms.adapter in sync so npm run doctor checks the right
one). No re-scaffold or reinstall is needed.
Credentials per provider
| Provider | Env vars uncommented |
|---|---|
strapi | STRAPI_API_URL, NEXT_PUBLIC_STRAPI_API_URL (Next), STRAPI_API_TOKEN |
prepr | PREPR_ACCESS_TOKEN |
contentful | CONTENTFUL_SPACE_ID, CONTENTFUL_ENVIRONMENT, CONTENTFUL_CDA_TOKEN (optional: CONTENTFUL_CPA_TOKEN + CONTENTFUL_PREVIEW=true for drafts) |
Homepage fallback
The Propeller convention for a CMS-driven homepage: if the provider returns
null for getPage('home') (or the page has no blocks), the shop renders a
built-in <HomeFallback> instead. This lets a shop launch without a CMS and add
one later without touching the homepage route.
Non-homepage CMS slugs return 404 when the CMS is missing or the slug isn't found — there's no generic "page-not-found" fallback.
Adding a new provider
Providers live in the Next boilerplate at lib/cms/providers/ and are
registered in the runtime switch in lib/cms/index.ts. Each implements the
CmsProvider contract:
interface CmsProvider {
getPage(slug: string, options?: CmsPageOptions): Promise<CmsPage | null>;
getAllPageSlugs(): Promise<string[]>;
getGlobal(): Promise<CmsGlobal | null>;
getCategoryBanner(categoryId: string): Promise<CmsCategoryBanner | null>;
getArticles(): Promise<CmsArticle[]>;
getArticle(slug: string): Promise<CmsArticle | null>;
getAllArticleSlugs(): Promise<string[]>;
resolveImageUrl(path: string): string;
}
The bulk of the work is mapping: translating the CMS's response shape into the
normalized CmsPage / CmsBlock / CmsGlobal / CmsArticle types in
lib/cms/types.ts. Use lib/cms/providers/strapi.ts (REST),
prepr.ts (GraphQL + runtime schema introspection), or contentful.ts
(GraphQL + Rich Text → HTML) as starting points.
To surface a new provider through the scaffolder:
- Add it to the boilerplate's
lib/cms/index.tsswitch and its.env.local.examplecredential block. - In the accelerator, extend the
CmsAdapterenum inpackages/cli/src/schema/propellerJson.ts, add a prompt choice and--cmshelp entry, add its credential lines toactivateCmsProviderEnvinpackages/cli/src/commands/scaffold.ts, and add acms/README.mdcase inpackages/cli/src/template/cmsReadme.ts.