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.
UI Capabilities
Section titled “UI Capabilities”Every MFE needs these three. They give your service the HEMA look, consistent navigation, and multi-language support.
1. HEMA Design System (Tompouce/HDS)
Section titled “1. HEMA Design System (Tompouce/HDS)”- 📖 Storybook: https://hema-design-system-dev.enterprise-dev-libraries.ui.hema.digital/
- 💻 Repo: HemaEcom/hema-design-system
cd src/npm install @hema/hds-components-react @hema/hds-assets @hema/hds-tailwindcss-presetsnpm install -D tailwindcss@4 @tailwindcss/postcss postcsspostcss.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>;}2. Web Shell (Shared Application Layer)
Section titled “2. Web Shell (Shared Application Layer)”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:
| Capability | Description |
|---|---|
| Header & Navigation | Top bar, category menu, basket count, search |
| Footer & Newsletter | Footer links, newsletter subscription form |
| Analytics (GTM) | Google Tag Manager, data layer initialization, event tracking |
| Consent Management | Usercentrics integration, GDPR-compliant consent gating |
| Geo-Popup | Country detection, locale redirect suggestions |
| Global Message Banner | Marketing/promotional banners from CMS |
| Search | Initial search data + suggestions (via platform API) |
| Session & Basket | Shared session, basket count across MFEs |
| Error View | Shared error page component |
| Scroll-to-top | Scroll button on long pages |
| I18n Provider | Locale context and translations for shell UI |
The Shell lives in a route group layout ([locale]/(shop)/layout.tsx), NOT the root layout.
| Package | Purpose |
|---|---|
@hema/omni-web-app-shell-shell | Main Shell component (all UI above) |
@hema/omni-web-app-shell-core | Data fetching (header, footer, menu, dictionary from Sanity) |
@hema/omni-web-app-shell-analytics | GTM event tracking, consent state, reporting adapters |
@hema/omni-web-app-shell-platform-api | Platform API client (search, session, basket count) |
@hema/omni-web-app-shell-api-client | Type-safe API clients (Newsletter, Products) via Kiota |
npm install @hema/omni-web-app-shell-shell @hema/omni-web-app-shell-core @hema/omni-web-app-shell-analyticsRoot 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> );}3. Internationalization (i18n)
Section titled “3. Internationalization (i18n)”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)
npm install next-intli18n/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|.*\\.).*)',],};CMS & Content
Section titled “CMS & Content”If your MFE renders editorial content (pages, blogs, promotions), you’ll integrate with Sanity CMS.
4. Sanity CMS Integration
Section titled “4. Sanity CMS Integration”📐 Architecture Decision: ADR-0009 — Use Sanity directly for editorial content
npm install @sanity/client next-sanity @portabletext/react @sanity/image-urlrepositories/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.
Data & APIs
Section titled “Data & APIs”If your MFE needs product data or backend services, you’ll use Kong authentication and PODS.
5. Server Actions
Section titled “5. Server Actions”📐 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.
'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', ], },},6. Backend API Authentication (Kong)
Section titled “6. Backend API Authentication (Kong)”📐 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.
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.
7. GraphQL Client (Apollo for PODS)
Section titled “7. GraphQL Client (Apollo for PODS)”📐 Architecture Decision: ADR-0008 — Use PODS to get product data. PODS is the single source of truth for product data.
npm install @apollo/client@^4 @apollo/client-integration-nextjs graphqlimport { 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.
Platform & Quality
Section titled “Platform & Quality”These capabilities connect your MFE to the platform infrastructure and ensure quality.
8. Gateway Registration (Multi-Zone)
Section titled “8. Gateway Registration (Multi-Zone)”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.
9. Testing
Section titled “9. Testing”HEMA uses Vitest for unit tests and Playwright BDD for end-to-end tests.
npm install -D vitest @vitest/coverage-v8 @testing-library/react @testing-library/jest-dom jsdom vite-tsconfig-pathsnpm install -D @playwright/test playwright-bddvitest.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' }, },});10. Route Groups for Layout Variants
Section titled “10. Route Groups for Layout Variants”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.tsx11. Health Check Endpoint
Section titled “11. Health Check Endpoint”Every MFE needs a health endpoint for ALB health checks:
export async function GET() { return Response.json({ status: 'ok' });}