Strapi
@voltage/strapi connects NestJS to a Strapi headless CMS. It reads the Strapi navigation tree on every request, matches the URL to a navigation node, and calls the corresponding @Page() controller to render the response. Content inside a page is broken into components, each rendered by its own @Element() controller. The locale for the request is provided by @voltage/intl — no additional locale handling is needed.
Installation
Section titled “Installation”yarn add @voltage/strapi @voltage/intl @voltage/jsx @voltage/logger @voltage/event-manager @voltage/zod @nestjs/axios @nestjs/cache-manager axios cache-managerBasic usage
Section titled “Basic usage”forRoot()
Section titled “forRoot()”Call forRoot() once in your root module:
import { StrapiModule } from '@voltage/strapi';
@Module({ imports: [ IntlModule.forRoot({ ... }), StrapiModule.forRoot({ url: 'https://cms.example.com', websiteUrl: 'https://example.com', token: process.env.STRAPI_TOKEN, version: 5, cache: { ttl: 60_000 }, }), ],})export class AppModule {}When routing is not set to false, a middleware is registered automatically. On each request it extracts the locale (via IntlModule), matches the path against the Strapi navigation tree, and calls the matching page controller to produce a response. Non-matching paths pass through to the next handler.
forRootAsync()
Section titled “forRootAsync()”StrapiModule.forRootAsync({ inject: [AppConfiguration], useFactory: (config: AppConfiguration) => ({ url: config.strapi.url, websiteUrl: config.strapi.websiteUrl, token: config.strapi.token, version: 5, }),})forFeature()
Section titled “forFeature()”Register element controllers in the module that owns them:
@Module({ imports: [StrapiModule.forFeature([HeroElement, NewsletterElement])],})export class ElementModule {}Page controllers are discovered globally — register them as providers in any module that has access to the services they need.
A page controller handles a full page render for a specific Strapi page type. Decorate it with @Page() and implement PageController:
import { Page, PageController, Client, Renderer, NodeMatch } from '@voltage/strapi';
@Page('default')@Injectable()export class DefaultPageView implements PageController { constructor( private readonly client: Client, private readonly renderer: Renderer, ) {}
async render(request: Request, match: NodeMatch): Promise<JSX.Element> { const page = await this.client.fetchSingle<DefaultPage>({ path: 'pages', id: match.node.attributes.page?.id, populate: ['deep,6'], });
const props = { match, request }; const content = page?.attributes?.content ?? [];
const head = await this.renderer.renderHead(content, props); const elements = await this.renderer.renderElements(content, props);
head.push(<title>{page?.attributes?.title}</title>);
return <DefaultLayout head={head}>{elements}</DefaultLayout>; }}The string passed to @Page() must match the page type field on the corresponding Strapi navigation node. match.node is the matched node, match.locale is the current locale, and match.path is any remaining path segment after the node’s URL — use it to distinguish list and detail views on the same page type:
async render(request: Request, match: NodeMatch): Promise<JSX.Element> { const [slug] = match.path.split('/');
if (slug) { return this.renderDetail(slug, match, request); }
return this.renderList(match, request);}Elements
Section titled “Elements”An element controller renders a single Strapi component. Decorate it with @Element() and implement ElementController<T>, where T is the shape of the component’s data:
import { Element, ElementController, Component, ElementProps, MediaHelper, Navigation } from '@voltage/strapi';
export interface Hero { headline?: string; subline?: string; image?: Entity<Media>; links: Array<{ title: string; node: Entity<NavigationNode> }>;}
@Element('element.hero')@Injectable()export class HeroElement implements ElementController<Hero> { constructor( private readonly media: MediaHelper, private readonly navigation: Navigation, ) {}
render(component: Component<Hero>, props: ElementProps): JSX.Element { const { headline, subline, image, links } = component;
return ( <section> <h1>{headline}</h1> <p>{subline}</p> {image && <img src={this.media.url(image)} alt={headline} />} <nav> {links.map((link) => ( <a href={this.navigation.url(link.node)}>{link.title}</a> ))} </nav> </section> ); }}The string passed to @Element() must match the __component field Strapi sets on the component — typically in the form category.name. The props argument carries match and request from the page that triggered the render.
Fetching data
Section titled “Fetching data”Client is the typed HTTP client for the Strapi API. Inject it in any page or element controller that needs to fetch content.
Single entry
Section titled “Single entry”const page = await this.client.fetchSingle<DefaultPage>({ path: 'pages', id: match.node.attributes.page?.id, populate: ['deep,6'], locale: match.locale,});Collection
Section titled “Collection”const articles = await this.client.fetchCollection<Article>({ path: 'articles', locale: match.locale, filters: { date: { $lte: new Date().toISOString().split('T')[0] }, published: { $eq: true }, }, sort: ['date:desc'], limit: 10,});
articles.data.forEach((article) => { const { title, slug } = article.attributes;});First entry
Section titled “First entry”const article = await this.client.fetchFirst<Article>({ path: 'articles', filters: { slug: { $eq: slug } },});
if (!article) throw new NotFoundException();Filter operators
Section titled “Filter operators”Strapi’s filter operators are fully typed on the filters parameter:
$eq, $ne — equal / not equal$lt, $lte — less than / less than or equal$gt, $gte — greater than / greater than or equal$in, $notIn — in array / not in array$contains — contains substring (case-sensitive)$containsi — contains substring (case-insensitive)$null — is null / not null$startsWith — starts with$endsWith — ends with$between — between two values$or, $and, $not — logical operatorsResponses are cached per-request. Identical calls within the same TTL window return the cached result; stale responses are refreshed in the background.
Navigation
Section titled “Navigation”Navigation resolves URLs, builds breadcrumbs, and provides access to the full navigation tree.
URL generation
Section titled “URL generation”const url = await this.navigation.url(link.node);// → "https://example.com/en/about"
// Different localeconst deUrl = await this.navigation.url(link.node, { locale: 'de' });// → "https://example.com/de/about"
// With appended pathconst detailUrl = await this.navigation.url(match.node, { path: article.attributes.slug });// → "https://example.com/en/news/my-article"Breadcrumbs
Section titled “Breadcrumbs”const breadcrumb = await this.navigation.trace(match.node);// → [rootNode, parentNode, currentNode]Navigation tree
Section titled “Navigation tree”const tree = await this.navigation.tree(rootNode, { depth: 2 });Manual matching
Section titled “Manual matching”const match = await this.navigation.match('en', 'about/team');// → { node, path: '', locale: 'en' } or null if not foundMediaHelper resolves full URLs for Strapi media entities, including format variants:
import { MediaHelper } from '@voltage/strapi';
@Element('element.image')@Injectable()export class ImageElement implements ElementController<ImageComponent> { constructor(private readonly media: MediaHelper) {}
render(component: Component<ImageComponent>): JSX.Element { return ( <picture> <source srcset={this.media.url(component.image, 'large')} media="(min-width: 1024px)" /> <source srcset={this.media.url(component.image, 'medium')} media="(min-width: 768px)" /> <img src={this.media.url(component.image, 'small')} alt={component.image?.attributes?.alternativeText ?? ''} /> </picture> ); }}url() returns undefined if the media entity or the requested format is absent — guard or use optional chaining in templates where the image is not guaranteed.
Available formats: 'thumbnail', 'small', 'medium', 'large'. Calling url() without a format returns the original upload URL.
Page head injection
Section titled “Page head injection”renderHead() fires PageHeadEvent for each component in the page. Any injectable service can listen and push elements into event.head — useful for component-driven meta tags, fonts, or structured data:
import { OnEvent } from '@voltage/event-manager';import { PageHeadEvent } from '@voltage/strapi';
@Injectable()export class SeoService { @OnEvent(PageHeadEvent) onPageHead(event: PageHeadEvent): void { const seo = event.components.find((c) => c.__component === 'shared.seo');
if (seo) { event.head.push(<meta name="description" content={seo.description} />); event.head.push(<meta property="og:title" content={seo.title} />); } }}The page controller collects whatever was pushed into event.head and passes it to the layout:
const head = await this.renderer.renderHead(content, props);head.push(<title>{page.attributes.title}</title>);
return <Layout head={head}>{elements}</Layout>;Type augmentation
Section titled “Type augmentation”NavigationNode has a fixed set of fields from Strapi’s navigation plugin. Augment it via module declaration to add the custom fields your Strapi instance exposes — for example, a relation to the page content type:
import { Entity, NavigationNode } from '@voltage/strapi';
declare module '@voltage/strapi' { interface NavigationNode { page: Entity<DefaultPage> | null; externalPath: string | null; }}After augmenting, match.node.attributes.page is fully typed everywhere.
Configuration
Section titled “Configuration”interface StrapiConfiguration { /** Base URL of the Strapi API (e.g. https://cms.example.com). */ url: string; /** Base URL of the website (e.g. https://example.com). Used for building navigation URLs. */ websiteUrl: string; /** Strapi API bearer token. */ token: string; /** Strapi major version. Defaults to 4. */ version?: 4 | 5; /** Response cache settings. */ cache?: { /** Cache TTL in milliseconds. Defaults to 60. */ ttl?: number; }; /** * Whether to register the routing middleware automatically. * When true, every request is matched against the navigation tree. * Defaults to true. */ routing?: boolean;}