Skip to content

6. Core Capabilities

This is where your MFE becomes a real HEMA service. Each capability below is something you’ll integrate — not all at once, but as your service needs them.


Every MFE needs these three. They give your service the HEMA look, consistent navigation, and multi-language support.

Diagram
Terminal window
cd src/
npm install @hema/hds-components-react @hema/hds-assets @hema/hds-tailwindcss-presets
npm install -D tailwindcss@4 @tailwindcss/postcss postcss

postcss.config.mjs:

const postcssConfig = {
plugins: {
'@tailwindcss/postcss': {},
},
};
export default postcssConfig;

styles/globals.css (actual pattern from both MFEs):

@import 'tailwindcss';
@import '@hema/hds-tailwindcss-presets';

Use the HEMA font in your root layout:

import { hurmeHema } from '@hema/hds-assets/next/font';
<html lang={locale} className={hurmeHema.variable}>
<body className="hds:overflow-x-hidden hds:bg-light-primary hds:dark:bg-dark-primary hds:text-light-primary hds:dark:text-dark-primary">

Use components:

import { Button } from '@hema/hds-components-react';
export function MyComponent() {
return <Button variant="primary">Click me</Button>;
}

The Web Shell is more than just a header and footer — it’s the shared application layer that every MFE plugs into. It provides the consistent HEMA experience and cross-cutting concerns so individual MFEs don’t have to reinvent them.

What the Shell provides:

CapabilityDescription
Header & NavigationTop bar, category menu, basket count, search
Footer & NewsletterFooter links, newsletter subscription form
Analytics (GTM)Google Tag Manager, data layer initialization, event tracking
Consent ManagementUsercentrics integration, GDPR-compliant consent gating
Geo-PopupCountry detection, locale redirect suggestions
Global Message BannerMarketing/promotional banners from CMS
SearchInitial search data + suggestions (via platform API)
Session & BasketShared session, basket count across MFEs
Error ViewShared error page component
Scroll-to-topScroll button on long pages
I18n ProviderLocale context and translations for shell UI

The Shell lives in a route group layout ([locale]/(shop)/layout.tsx), NOT the root layout.

PackagePurpose
@hema/omni-web-app-shell-shellMain Shell component (all UI above)
@hema/omni-web-app-shell-coreData fetching (header, footer, menu, dictionary from Sanity)
@hema/omni-web-app-shell-analyticsGTM event tracking, consent state, reporting adapters
@hema/omni-web-app-shell-platform-apiPlatform API client (search, session, basket count)
@hema/omni-web-app-shell-api-clientType-safe API clients (Newsletter, Products) via Kiota
Terminal window
npm install @hema/omni-web-app-shell-shell @hema/omni-web-app-shell-core @hema/omni-web-app-shell-analytics

Root layout.tsx — minimal, fonts + providers only:

import { hurmeHema } from '@hema/hds-assets/next/font';
import { NextIntlClientProvider } from 'next-intl';
import { getMessages, getLocale } from 'next-intl/server';
import '../styles/global.css';
export default async function RootLayout({ children }) {
const locale = await getLocale();
const messages = await getMessages({ locale });
return (
<html lang={locale} className={hurmeHema.variable}>
<body className="hds:overflow-x-hidden hds:bg-light-primary">
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}

[locale]/(shop)/layout.tsx — Shell wraps content pages:

import { getCategoryMenu, getFooter, getHeader, getShellDictionary } from '@hema/omni-web-app-shell-core';
import { Shell } from '@hema/omni-web-app-shell-shell';
import { DEFAULT_CONSENT } from '@hema/omni-web-app-shell-analytics';
import { baseClient } from '@/repositories/sanity/client';
export default async function ShopLayout({ children }) {
const locale = await getLocale();
const [footerData, headerData, categoryMenuItems, shellDictionary] = await Promise.all([
getFooter({ locale, client: baseClient }),
getHeader({ locale, client: baseClient }),
getCategoryMenu({ locale, client: baseClient }),
getShellDictionary(locale),
]);
return (
<Shell
locale={locale}
headerData={headerData}
footerData={footerData}
menuItems={categoryMenuItems}
messages={shellDictionary}
environment={process.env.ENVIRONMENT || 'test'}
initialConsent={DEFAULT_CONSENT}
pageType="content"
>
{children}
</Shell>
);
}

HEMA uses next-intl with domain-based routing:

  • hema.nl → Dutch (nl-nl)
  • hema.com → Belgian Dutch, French, German (nl-be, fr-fr, fr-be, de-de)
Terminal window
npm install next-intl

i18n/routing.ts:

import { defineRouting } from 'next-intl/routing';
export const locales = ['nl-nl', 'fr-fr', 'fr-be', 'de-de', 'nl-be'] as const;
// Domains are configurable via env vars for local dev vs production.
const stripProtocol = (url: string) => url.replace(/^https?:\/\//, '');
const domainNL = stripProtocol(process.env.NEXT_PUBLIC_DOMAIN_NL || 'nl.localhost:3000');
const domainCOM = stripProtocol(process.env.NEXT_PUBLIC_DOMAIN_COM || 'com.localhost:3000');
export const routing = defineRouting({
locales,
defaultLocale: 'nl-nl',
localeDetection: false,
localePrefix: {
mode: 'as-needed',
prefixes: {
'nl-be': '/nl-be',
'fr-fr': '/fr-fr',
'fr-be': '/fr-be',
'de-de': '/de-de',
},
},
domains: [
{ localePrefix: 'never', domain: domainNL, defaultLocale: 'nl-nl', locales: ['nl-nl'] },
{ localePrefix: 'always', domain: domainCOM, defaultLocale: 'nl-be', locales: ['nl-be', 'fr-fr', 'fr-be', 'de-de'] },
],
});

middleware.ts:

import { NextRequest, NextResponse } from 'next/server';
import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';
const intlMiddleware = createMiddleware(routing);
export default function middleware(request: NextRequest): NextResponse {
const response = intlMiddleware(request);
const rewriteUrl = response.headers.get('x-middleware-rewrite');
const pathname = rewriteUrl
? new URL(rewriteUrl).pathname
: request.nextUrl.pathname;
response.headers.set('x-pathname', pathname);
response.headers.set('x-search-params', request.nextUrl.search);
return response;
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|_zones/.+/_next/image|favicon.ico|images|sitemap|\\.well-known|.*\\.).*)',],
};

If your MFE renders editorial content (pages, blogs, promotions), you’ll integrate with Sanity CMS.

Diagram

📐 Architecture Decision: ADR-0009 — Use Sanity directly for editorial content

Terminal window
npm install @sanity/client next-sanity @portabletext/react @sanity/image-url

repositories/sanity/client.ts:

import { createClient } from '@sanity/client';
export const baseClient = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
apiVersion: process.env.NEXT_PUBLIC_SANITY_API_VERSION!,
useCdn: true,
token: process.env.SANITY_API_READ_TOKEN,
});

This client is used by both your page queries and the Web Shell (header/footer data).

For content modeling, GROQ query patterns, live preview setup, and draft mode, see the CMS Integration guide.


If your MFE needs product data or backend services, you’ll use Kong authentication and PODS.

Diagram

📐 Architecture Decision: ADR-0012 — Server actions vs API routes. Use server actions by default; use API routes only when the endpoint must be accessible by other applications.

server-actions/pods/pods.ts
'use server';
import { PodsService } from '@/services/pods/pods-service';
import { PodsRepository } from '@/repositories/pods/pods-repository';
const podsService = new PodsService(new PodsRepository());
export async function getProductData(skus: string[], locale: string) {
return podsService.getProducts(skus, locale);
}

Configure allowed origins in next.config.ts:

experimental: {
serverActions: {
allowedOrigins: process.env.ALLOWED_ORIGINS?.split(',') || [
'*.hema.nl',
'*.hema.com',
],
},
},

📐 Architecture Decision: ADR-0015 — Auth with API management (Kong). Services authenticate via OAuth2 client credentials; tokens are rotated at runtime, never baked into the app.

services/auth/kong-authenticator.ts
import 'server-only';
export class KongAuthenticator {
async getToken(): Promise<string> {
// Checks cache → refreshes proactively → fetches new token if expired
// See full implementation in the Kong Authentication guide
}
async getAuthHeaders(): Promise<Headers> {
const token = await this.getToken();
return new Headers({
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
});
}
}

For the full implementation, see the Kong Authentication guide.

📐 Architecture Decision: ADR-0008 — Use PODS to get product data. PODS is the single source of truth for product data.

Terminal window
npm install @apollo/client@^4 @apollo/client-integration-nextjs graphql
clients/graphql/apollo-client-rsc.ts
import { registerApolloClient, ApolloClient, InMemoryCache } from '@apollo/client-integration-nextjs';
import { HttpLink, from } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
export function createApolloQueryFn(authenticator: Authenticator) {
const authLink = setContext(async (_, { headers }) => ({
headers: { ...headers, authorization: `Bearer ${await authenticator.getToken()}` },
}));
const { query } = registerApolloClient(() => {
const httpLink = new HttpLink({ uri: `${process.env.BASE_API_URL}/omni-products/v1/graphql` });
return new ApolloClient({ link: from([authLink, httpLink]), cache: new InMemoryCache() });
});
return query;
}

For PODS store IDs per country and query patterns, see the PODS Integration guide.


These capabilities connect your MFE to the platform infrastructure and ensure quality.

Your MFE registers its routes with the omni-web-gateway so CloudFront routes traffic to your service. This is handled by CDK using @hema/omni-web-gateway-management-library-constructs.

For the full CDK configuration, see the Gateway Registration guide.

HEMA uses Vitest for unit tests and Playwright BDD for end-to-end tests.

Terminal window
npm install -D vitest @vitest/coverage-v8 @testing-library/react @testing-library/jest-dom jsdom vite-tsconfig-paths
npm install -D @playwright/test playwright-bdd

vitest.config.mts:

import { defineConfig } from 'vitest/config';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
coverage: { provider: 'v8' },
},
});

Use route groups (groupName) to apply different layouts without affecting the URL:

src/app/
├── layout.tsx # Root layout (html, body, fonts)
├── [locale]/
│ └── (shop)/ # Route group — Shell wraps these pages
│ ├── layout.tsx # Shell layout (header, footer, analytics)
│ ├── [...slug]/page.tsx # Product pages
│ └── not-found.tsx

Every MFE needs a health endpoint for ALB health checks:

app/api/health/route.ts
export async function GET() {
return Response.json({ status: 'ok' });
}