Add a page
When you need this
Section titled “When you need this”You want a new public URL on the storefront, for example /fr/team and /en/team. Every customer-facing route lives under the [locale] segment, is server-rendered by Nitro, and carries the head tags a crawler and a link preview read. This page walks the About page at /{locale}/about, which reads one CMS page at SSR time. You end with a page component, an optional .server.ts loader, the meta block, a link from the chrome through the route helper, and a journey spec that runs under every locale of the fixture and fails on any console error.
Files you touch
Section titled “Files you touch”apps/storefront/src/app/pages/[locale]/about.page.ts: the route component. The file name is the path: Analog mapspages/[locale]/about.page.tsto/:locale/about.apps/storefront/src/app/pages/[locale]/about.server.ts: the loader, only when the page needs data at first byte. It exportsload, which Analog wraps into a Nitro endpoint under/api/_analog/pages/....apps/storefront/src/lib/routes.ts:APP_ROUTES, the one place a path is written. Add an entry and link through it.apps/storefront/src/lib/seo.ts:staticPageMetaTags, which builds title, description, canonical,hreflangand the Open Graph block for a static path, andpageTitle, which appends the brand once. Its input type isStaticPageMetaInputat lines 837-847.apps/storefront/src/app/pages/[locale]/about.page.spec.ts: the component test. It feeds the loader payload throughActivatedRoute.data.apps/storefront/src/app/layout/footer.component.spec.ts: only when you add a footer link. It pins the footer at exactly six routed links and their label order in both locales (lines 172-193) and the sixdata-testidvalues (lines 310-317), so a seventh link means updating both lists.apps/storefront/src/app/title-suffix.guard.spec.tsandapps/storefront/src/app/pages/[locale]/loader-query.guard.spec.ts: guards you do not edit. The first walks every page file and requires eachsetTitlecall to go throughpageTitle; the second walks every.server.tscompanion and requires the query to be read throughloaderSearchParams. Both pass when the page copiesabout.page.tsandabout.server.ts.apps/storefront-e2e/src/cms-pages.journey.spec.tsandapps/storefront-e2e/src/newsletter.journey.spec.ts: the two journey shapes to copy, one read-only, one with a mutation and the reload step.apps/storefront-e2e/playwright.config.ts: every spec file is named in a project’stestMatchallowlist. Add yours.
The pattern
Section titled “The pattern”1. Decide whether the page needs a loader
Section titled “1. Decide whether the page needs a loader”Two shapes exist. apps/storefront/src/app/pages/[locale]/newsletter/confirm.page.ts has no loader: it reads ?status= from ActivatedRoute and renders chrome strings. about.page.ts has a sibling about.server.ts because the copy comes from the API and must be in the SSR HTML. The rule the existing pages follow: a page a crawler should index gets a loader; a private page (account/*, cart, checkout) fetches in the browser behind isPlatformBrowser and never has one.
2. Write the loader
Section titled “2. Write the loader”apps/storefront/src/app/pages/[locale]/about.server.ts builds a request-scoped API client, passes the locale from the route params, and turns an upstream 404 into a loader 404:
export const load = async ({ params, event }: PageServerLoad): Promise<AboutLoadPayload> => { const apiClient = createServerApiClient( event.node.req as ServerRequest, event.node.res as ServerResponse, { locale: routeLocale(params) }, );
try { const envelope = await apiClient.get<ApiResponse<CmsPage>>(`/v1/pages${ABOUT_CMS_SLUG}`); return { page: envelope.data }; } catch (err) { if (readErrorStatus(err) === 404) { throw createError({ statusCode: 404, statusMessage: 'About page not found' }); } throw err; }};routeLocale(params) is required. Inside a Nitro page endpoint the request URL is the internal /api/_analog/pages/<locale>/... path, which the URL-prefix parser cannot read, so a loader that omits the option renders the default locale on every page. Load data at SSR covers the client, cookies and query forwarding.
3. Read the payload in the component
Section titled “3. Read the payload in the component”apps/storefront/src/app/pages/[locale]/about.page.ts seeds its signals synchronously from the loader and applies the head in ngOnInit:
protected readonly loaded = toSignal(injectLoad<typeof load>(), { requireSync: true });
protected readonly heading = computed(() => this.loaded().page.title);protected readonly body = computed(() => this.loaded().page.content);
ngOnInit(): void { const t = this.tags(); this.titleSvc.setTitle(pageTitle(t.title, this.localeService.strings().brand.name)); this.metaSvc.updateTag({ name: 'description', content: t.description }); this.metaSvc.updateTag({ property: 'og:title', content: t.og.title }); this.metaSvc.updateTag({ property: 'og:url', content: t.og.url }); this.metaSvc.updateTag({ property: 'og:locale', content: t.og.locale }); upsertPropertyMulti(this.document, 'og:locale:alternate', t.og.localeAlternate); this.upsertCanonical(t.canonical); this.upsertHreflang(t.hreflang); this.applyJsonLd();}import type { load } from './about.server' keeps the loader out of the browser bundle. requireSync: true throws if the payload is not there when the component is built, which is the contract Analog’s resolver gives.
4. Build the head from one call
Section titled “4. Build the head from one call”The same file builds the tag set with staticPageMetaTags from apps/storefront/src/lib/seo.ts:
protected readonly tags = computed(() => { const page = this.loaded().page; const fallbackDescription = stripHtml(page.content).slice(0, 200).trim(); return staticPageMetaTags({ locale: this.locale(), baseUrl: this.resolveBaseUrl(), locales: this.storeConfig.seoLocales(), identity: this.localeService.chromeIdentity(), path: '/about', title: page.metaTitle ?? page.title, description: page.metaDescription ?? fallbackDescription, });});The two config-shaped arguments have named types. StaticPageMetaInput in apps/storefront/src/lib/seo.ts (lines 837-847) is the contract:
export interface StaticPageMetaInput { locale: Locale; baseUrl: string; /** The store's name and country, filled into the chrome copy. */ identity: ChromeIdentity; /** The store's locale set and country, for hreflang and og:locale. */ locales: SeoLocaleContext; /** Path under the locale, e.g. `/about` or `/policies/privacy`. */ path: string; title: string; description: string;}identity is what LocaleService.chromeIdentity() returns and locales is what StoreConfigService.seoLocales() returns; pass those two signals through rather than building the objects by hand.
The builder appends the brand to the title unless it is already there, clamps the description, sets the canonical to ${baseUrl}/${locale}${path}, and emits one hreflang per locale in seoLocales().supportedLocales plus x-default at the default locale. The region tail (fr-XX, en_XX) comes from the configured country through apps/storefront/src/lib/locale-region.ts; a store with no country emits the bare language tag.
5. Name the route once and link through it
Section titled “5. Name the route once and link through it”Add the path to APP_ROUTES in apps/storefront/src/lib/routes.ts:
about: (locale: Locale) => `/${locale}/about`,Then link with it. apps/storefront/src/app/layout/footer.component.ts builds its links from the helper and binds them with [routerLink]:
href: APP_ROUTES.about(loc),No component or template writes /fr/... by hand. apps/storefront/src/lib/routes.spec.ts pins the helper’s output.
6. Write the journey spec
Section titled “6. Write the journey spec”apps/storefront-e2e/src/newsletter.journey.spec.ts is the full shape: helpers from ./support, the console baseline around every test, the locale matrix, per-locale copy through copyFor, and the reload step after a mutation:
import { attachConsoleBaseline, copyFor, describeBothLocales, reloadAndAssertClean, type ConsoleBaseline,} from './support';
const HEADING = { en: 'Newsletter', fr: 'Lettre d’information' } as const;
describeBothLocales('storefront NE9 newsletter', (locale) => { let baseline: ConsoleBaseline; test.beforeEach(({ page }) => { baseline = attachConsoleBaseline(page); }); test.afterEach(() => { baseline.assertClean(); });
test(`footer signup renders the localized heading (${locale})`, async ({ page }) => { await page.goto(`/${locale}`); await expect(page.getByTestId('newsletter-signup')).toContainText(copyFor(HEADING, locale)); });});describeBothLocales (in apps/storefront-e2e/src/support/locale-matrix.ts) reads the locale set from the active scenario fixture and runs the body once per locale as its own describe. attachConsoleBaseline (support/console-baseline.ts) records every pageerror and console.error, and assertClean() throws with the list. reloadAndAssertClean(page, route, baseline) (support/reload-and-assert.ts) navigates to /, back to route, then re-asserts the baseline. Finish by adding the file name to the testMatch of the desktop-chromium-1440 project in apps/storefront-e2e/playwright.config.ts.
Tests to run
Section titled “Tests to run”npx nx test storefrontnpx nx lint storefrontnpx nx e2e storefront-e2e --grep=cms-pagesnpm run test:storefront-ssr-smokenpx nx test storefrontruns the page spec,apps/storefront/src/lib/routes.spec.ts, andapps/storefront/src/app/pages/[locale]/ssr-loaders-locale.spec.ts, which feeds every loader the internal endpoint URL and assertsAccept-Languagefollowsparams.locale. Add your loader to itsCASESlist. The same run includestitle-suffix.guard.spec.tsandloader-query.guard.spec.ts, which walk every page and loader file on their own and need no entry.npx nx lint storefrontruns the module-boundary and logical-CSS rules on the new component.- The e2e line runs one journey against the dev server and the seeded test API. Replace
cms-pageswith your spec’s name. - The smoke boots the production Nitro build; run it when your loader reads a query string, because the dev server cannot reproduce that path.
Gotchas
Section titled “Gotchas”- A spec file absent from every
testMatchinapps/storefront-e2e/playwright.config.tscollects zero tests and the run is green.apps/storefront-e2e/src/project-coverage.spec.tsfails on such a file, so add the name before you rely on the run. - A loader that throws 404 rejects Analog’s resolver, which cancels the navigation before any component mounts and would ship a 200 with an empty
<main>.apps/storefront/src/app/app.config.tsmaps it to/{locale}/not-foundwithskipLocationChange, and the not-found page sets the 404 status throughinjectResponse(). That target must never get a loader of its own, or the handler loops. - Do not emit a
<main>in a page.apps/storefront/src/app/layout/layout-shell.component.tswraps every route in one, and a second fails the axelandmark-one-mainrule. - Read
process.envonly behindisPlatformServer.about.page.tsresolves the public base URL that way becauseprocessis undefined in the browser bundle and a bare read throws in everyngOnInit. - Apply the head in
ngOnInit, not in aneffect(). An effect fires once on the server and again after hydration, and the second run can overwrite a tag with stale data. - A CMS page that has no dedicated route goes through
cmsPageRouteinroutes.tsand lands under/{locale}/pages/<slug>. A top-level[slug].page.tswould shadow any of the fourteen static segments the[locale]directory already owns. - A hand-rolled
for (const locale of ['fr', 'en'])pins one install’s locale pair. UsedescribeBothLocales;copyForthrows with the locale name when your copy map lacks the locale the fixture serves. - In a Vitest component spec the jsdom environment configured in
apps/storefront/vite.config.tsgiveslocation.originthe valuehttp://localhost:3000, not the dev server’s 53300.about.page.spec.tsassertsog:urlagainstglobalThis.location.originfor that reason; a canonical orog:urlassertion that hardcodes the 53300 origin fails. - A
fill()against SSR HTML sets the DOM value and hydration then clears it. UsewaitForHydration(page)fromapps/storefront-e2e/src/support/hydration.tsbefore typing into a form.