Skip to content

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.

Terminal window
yarn add @voltage/strapi @voltage/intl @voltage/jsx @voltage/logger @voltage/event-manager @voltage/zod @nestjs/axios @nestjs/cache-manager axios cache-manager

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.

StrapiModule.forRootAsync({
inject: [AppConfiguration],
useFactory: (config: AppConfiguration) => ({
url: config.strapi.url,
websiteUrl: config.strapi.websiteUrl,
token: config.strapi.token,
version: 5,
}),
})

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

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.

Client is the typed HTTP client for the Strapi API. Inject it in any page or element controller that needs to fetch content.

const page = await this.client.fetchSingle<DefaultPage>({
path: 'pages',
id: match.node.attributes.page?.id,
populate: ['deep,6'],
locale: match.locale,
});
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;
});
const article = await this.client.fetchFirst<Article>({
path: 'articles',
filters: { slug: { $eq: slug } },
});
if (!article) throw new NotFoundException();

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 operators

Responses are cached per-request. Identical calls within the same TTL window return the cached result; stale responses are refreshed in the background.

Navigation resolves URLs, builds breadcrumbs, and provides access to the full navigation tree.

const url = await this.navigation.url(link.node);
// → "https://example.com/en/about"
// Different locale
const deUrl = await this.navigation.url(link.node, { locale: 'de' });
// → "https://example.com/de/about"
// With appended path
const detailUrl = await this.navigation.url(match.node, { path: article.attributes.slug });
// → "https://example.com/en/news/my-article"
const breadcrumb = await this.navigation.trace(match.node);
// → [rootNode, parentNode, currentNode]
const tree = await this.navigation.tree(rootNode, { depth: 2 });
const match = await this.navigation.match('en', 'about/team');
// → { node, path: '', locale: 'en' } or null if not found

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

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>;

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.

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;
}