Multilingual (i18n)
Since April 2026, the portal is fully available in English and German. End customers switch the language at any time via a flag dropdown at the top right of the header, and the choice is stored in LocalStorage (licensio_locale).
Architecture
The i18n package lives as its own workspace under packages/i18n/:
packages/i18n/
├── src/
│ ├── index.ts → re-exports (server-safe)
│ ├── translate.ts → reine t()-Funktion, kein React
│ ├── LanguageContext.tsx → 'use client' Provider + useLanguage
│ ├── LanguageDropdown.tsx → 'use client' EN/DE-Dropdown
│ ├── server.ts → getServerLocale() Helper für Server Components
│ └── locales/
│ ├── en.ts → ~1.100 Keys, Single Source of Truth
│ └── de.ts → typed gegen en (TS-Error bei fehlenden Keys)
├── package.json
└── ...
Apps import only from @licensio/i18n:
import { useLanguage, LanguageProvider, LanguageDropdown, t, type Locale } from '@licensio/i18n'
Usage
Client Components
'use client'
import { useLanguage } from '@licensio/i18n'
function MyComponent() {
const { t, locale, setLocale } = useLanguage()
return <h1>{t('portal.dashboard.title')}</h1>
}
With variable interpolation
t('portal.products.heading', { count: '12' })
// EN: "Products (12)"
// DE: "Produkte (12)"
Server Components
t() from @licensio/i18n is React-free and works on the server, but needs the locale explicitly:
import { t } from '@licensio/i18n'
const subject = t('de', 'email.welcome.subject')
For Server Components that should derive the locale from a URL param or the provider default, there is the helper getServerLocale() from @licensio/i18n (re-exported from server.ts): priority ?lang= (URL) → provider default_locale → 'en'. Synchronous, framework-agnostic.
import { getServerLocale, t } from '@licensio/i18n'
const locale = getServerLocale(searchParams, tenant.settings?.default_locale)
const title = t(locale, 'admin.something.title')
Note Next.js 16:
searchParamsis a Promise, so in the page firstawait searchParams, then pass it togetServerLocale.
For SSR pages with many dynamic strings, we alternatively extract the view layer into a client component (pattern: OverviewView.tsx, LicenseTabView.tsx, LegalPageView.tsx).
In addition, the LanguageProvider (client) reads the ?lang= URL param on first paint (before LocalStorage, before the default prop), so deep links from the marketing site (…?lang=en) also work in the portal/admin.
Provider settings
Configurable per provider in tenants.settings:
default_locale:'en'or'de': default language on first visit (overridable via the dropdown)enabled_locales:['en', 'de']: restricts the available languages. If only one language is allowed, the dropdown does not render.
Backwards compat: if default_locale is not set, the LanguageProvider falls back to 'de', which protects live providers without a migration.
Translated areas (Portal)
100% translated:
- Auth: login, OTP flow, password reset (banners/errors/success)
- Dashboard: sidebar, overview KPIs, downloads, notifications (incl. time-ago + notification badges)
- Products: list (filter/search/cards), detail (print settings, accessories, base parts), feed export (complete 738-line form)
- Account: all 4 tabs (My Account, License & Contract, Printers, CRM) with all sub-components (AccountEditForm with 15 countries, ChangeEmailModal, CancelSection, IntervalSection, InvoicesSection, LegalSection, PackageUsageSection)
- Support: FAQ, ticket form, ticket list, ticket detail (sender labels, locale-aware date formatting)
- Printers: PrinterList, PrinterForm (incl. IP help), printer detail (5 sections + 4-step setup wizard + modals)
- Join Flow: /join overview + /join/[packageSlug] registration form (4 steps: company → OTP → password → payment) + /join/success
- Register Flow: /register/[token] (full reg + checkout-only + invalid link), /register/payment, /register/success
- Public landing page: header (login/register), packages section (recommended badge, feature lists), footer (legal column + powered-by), 4 demo views of the portal preview (dashboard/products/assets/account)
- Components: OnboardingTour (5 steps), Lightbox, BackButton, LegalFooter, MobilePreview
- Misc: maintenance page, unsubscribe page, legal pages (Impressum/AGB/Datenschutz)
Date and number formatting
Locale-specific via Locale-aware helpers:
- DE:
de-DE(date:15.04.2026, numbers:20,00) - EN:
en-US(date:04/15/2026, numbers:20.00)
Example:
const { locale } = useLanguage()
const dateStr = new Date().toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US')
Adding new keys
- Add the key in
packages/i18n/src/locales/en.ts - Add the SAME key in
packages/i18n/src/locales/de.tswith the German translation, otherwise TypeScript reports a compile error (Record<TranslationKey, string>) - Use it in a component:
t('mein.neuer.key')
Translated areas (Admin)
The Admin area has been migrated systematically in tiers since May 2026. Tier 1+2 (12 components) are done; ~22 small files + 7 sub-components (Tier 3) are still pending.
Already translated:
- Auth: login (with floating dropdown at the top right), forgot password, reset password (Supabase manages the actual reset mail)
- Chrome: header (dropdown left of the email + LogoutButton), sidebar (nav items + unread counter)
- Dashboard: welcome heading, KPIs (products/active customers/downloads), summary, admin notifications + open tickets
- Customers: list (filter pills + status badges + activate button + toast messages), customer detail (
customer/[id]: all fields, packages, notes, activation/rejection, ~115 keys), invite form (package selection + 31 countries) - Products: list heading + import/export/create buttons, product editor (
products/[id]/edit: general/variants/print settings/accessories, ~50 keys) - Assets: AssetManager (logos / product images / accessories / base parts + empty states)
- Settings: advanced settings hub with 13 tiles, Language & Region page, billing (BillingForm, ~50 keys + 14 countries), branding (BrandingForm, ~40 keys), legal texts (LegalForm), Landing Page Builder, stub pages + section helpers, Danger Zone, email notifications page
- Support: ticket list + ticket detail, product suggestions tab (SuggestionList + detail panel)
- FAQs: FaqManager (CRUD + categories + templates), accessories library (AccessoriesManager)
- Onboarding: OnboardingChecklist (6-step popup) + OnboardingWizard
- Maintenance, Forgot, Reset: public-facing pages
Still open (Tier 3 / Phase 3): ~22 small files, including NotificationsManager, NewsletterManager, PrintFarm stub, IntervalEditor, ImageUploader, plus 7 sub-components (VariantEditor, AccessoryAutocomplete, ImageUploader, PrintSettingsEditor, CategorySelect, CompatibilitySelect, BasePartPicker) and 5 server-component headings. RegisterForm keeps its own inline EN/DE dict (already works bilingually, shares the LocalStorage key). DomainForm + StripeForm are still hardcoded German.
Email translation
The transactional email routes read from the central template system (apps/{admin,portal}/lib/email-templates.ts : 2 copies kept in sync). The locale is resolved country-aware (see resolveEmailLocale / resolveCustomerLanguage below). See Email Templates for the provider view of the 10 template slots + variables.
Migrated routes (as of 2026-05-28)
Admin:
/api/customers/[id]/activate:activationslot/api/customers/[id]/cancel:cancellationslot/api/customers/[id]/change-email:email_change_old+email_change_new(2 sends)/api/customers/[id]/reject:rejectionslot/api/register:welcomeslot
Portal:
/api/portal/cancel-subscription:cancellationslot/api/portal/cancel-subscription/withdraw:cancellation_withdrawslot/api/portal/change-interval:interval_changeslot
Helper API
import { renderEmailTemplate, resolveEmailLocale } from '@/lib/email-templates'
const { subject, body } = renderEmailTemplate(
'activation',
{
tenant_name: tenant.name,
kunde_firma: customer.company,
kundennummer: customer.customer_number,
support_email: tenant.settings.support_email,
},
tenant.settings,
)
renderEmailTemplate(slot, vars, tenantSettings, country?) does: (1) locale via resolveEmailLocale(tenantSettings?.default_locale, country), (2) overlay the provider custom template onto the default template (per field), (3) variable interpolation. The provider custom template wins per field; missing fields fall back to the locale default.
resolveEmailLocale(default_locale, country?) is country-aware (step E, outbound i18n): an explicit default_locale ('de'/'en') wins; otherwise the provider country decides: DE/AT → de, any other country → en. So a non-DE provider without default_locale gets English mails, while Plant Technology (country DE) stays German. All mails sent via getMailer() (invitation, payment link, OTPs, cancellation, interval, reject/activate, newsletter + notification broadcast, Stripe webhook activation) are localized through this; shared email.chrome.* keys carry footer/fallback-link/CTA buttons.
Non-migrated routes
/api/tickets/[id](ticket reply) : still uses the oldt(locale, key)pattern. Migration with the Stripe webhook sprint./api/portal/tickets(ticket forward) : see above.- Stripe webhook routes (portal) : out of scope until the Stripe webhook sprint.
Supabase-managed
Reset password email: the locale is configured via Supabase Studio email templates, outside the code and outside the template system.
Known limitations
- Hydration flicker: if the LocalStorage locale differs from the server default, the user sees the default language for ~30ms, then it switches. Acceptable: the marketing site has the same flicker.
- Provider user content (product names, FAQ texts, notifications) is not translated automatically: providers maintain content themselves in the desired language.
- Admin Tier 3 (open): StripeForm, DomainForm, NotificationsManager, NewsletterManager, PrintFarm stub and 7 form sub-components (
VariantEditor,AccessoryAutocomplete,ImageUploader,PrintSettingsEditor,CategorySelect,CompatibilitySelect,BasePartPicker) as well as 5 server-component headings are still hardcoded German.
Status
As of 2026-05-29. Portal 100% translated. Admin: Tier 1+2 completed (12 components, ~480 keys), Tier 3 mostly done (cleanup sprint 2026-05-21 to 26: 4 of 6 Tier-3 tickets fully carried through, landing-editor only partially done, the LandingPageBuilder still has ~30 hardcoded DE chrome strings, Danger Zone open). The Multi-Locale Foundation sprint migrated 7 email routes to the central renderEmailTemplate helper + a language dropdown at provider registration + default_locale in tenants.settings. Total dictionary ~1,100 keys in the @licensio/i18n workspace.
Complete inventory of the remaining hardcoded DE strings: docs/i18n-audit.md (in the repo).