Skip to Content
Core ConceptsLocalization

Localization

Hoikka supports multiple languages. The default language (English) is stored directly on entity tables for simplicity, while non-default languages use separate translation tables.

Language Configuration

Languages are configured in src/lib/config/languages.ts:

import { DEFAULT_LANGUAGE, // "en" LANGUAGES, // [{ code: "en", name: "English" }, { code: "fi", name: "Suomi" }] TRANSLATION_LANGUAGES, // [{ code: "fi", name: "Suomi" }] — excludes default translationsToMap // Utility for components } from "$lib/config/languages";
  • DEFAULT_LANGUAGE — The language stored on entity tables (e.g. products.name)
  • LANGUAGES — All supported languages
  • TRANSLATION_LANGUAGES — Languages that use translation tables (everything except default)
  • translationsToMap() — Converts translation rows into Record<langCode, Record<field, value>> for component consumption

How It Works

Default Language (English)

Default language fields live directly on entity tables:

products.name = "Blue Shirt" products.slug = "blue-shirt" products.description = "<p>A nice shirt</p>"

Services return these fields directly — no translation resolution needed:

const product = await productService.getById(123); product.name; // "Blue Shirt" — directly from the products table

Non-Default Languages (Finnish, etc.)

Non-default languages are stored in translation tables and managed through the TranslationService:

import { translationService } from "$lib/server/services/translations"; // Save a Finnish translation await translationService.upsertProductTranslation(productId, "fi", { name: "Sininen paita", slug: "sininen-paita", description: "<p>Hieno paita</p>" }); // Read translations const rows = await translationService.getProductTranslations(productId); // [{ languageCode: "fi", name: "Sininen paita", slug: "sininen-paita", ... }]

Translation Tables

Parent TableTranslation TableFields
productsproduct_translationsname, slug, description
product_variantsproduct_variant_translationsname
facetsfacet_translationsname
facet_valuesfacet_value_translationsname
collectionscollection_translationsname, slug, description
categoriescategory_translationsname
content_pagescontent_page_translationstitle, slug, body

Each translation table has a unique index on (entity_id, language_code). The TranslationService uses Drizzle’s onConflictDoUpdate for upserts.

TranslationService

A single service (src/lib/server/services/translations.ts) handles all 7 entities with a consistent API:

import { translationService } from "$lib/server/services/translations"; // Pattern: get + upsert per entity await translationService.getProductTranslations(productId); await translationService.upsertProductTranslation(productId, "fi", { name, slug, description }); await translationService.getVariantTranslations(variantId); await translationService.upsertVariantTranslation(variantId, "fi", { name }); await translationService.getFacetTranslations(facetId); await translationService.upsertFacetTranslation(facetId, "fi", { name }); await translationService.getAllFacetValueTranslations(facetId); // Bulk: all values for a facet await translationService.upsertFacetValueTranslation(facetValueId, "fi", { name }); await translationService.getCollectionTranslations(collectionId); await translationService.upsertCollectionTranslation(collectionId, "fi", { name, slug, description }); await translationService.getAllCategoryTranslations(); // Bulk: all categories await translationService.upsertCategoryTranslation(categoryId, "fi", { name }); await translationService.getContentPageTranslations(pageId); await translationService.upsertContentPageTranslation(pageId, "fi", { title, slug, body });

Admin UI

Translations are saved as part of the main entity form — there is no separate save action for translations. The update action in each +page.server.ts reads translation fields from the form data and calls the appropriate translationService.upsert*() method.

Translation field inputs use the naming convention {fieldName}_{langCode} (e.g. name_fi, slug_fi, description_fi).

Inline Language Tabs

Products, collections, and content pages render language tabs directly in the main form card. Each tab shows the translatable fields for that language, using a hidden class to keep all fields mounted (important for rich text editors).

TranslationEditor Component

For simpler entities (variants, facets), a shared TranslationEditor component (src/lib/components/admin/TranslationEditor.svelte) renders a separate “Translations” card. It uses the HTML form attribute to associate its inputs with the main form.

Inline Translation Editing

For entities with inline editing (categories, facet values), translation fields are added directly to the create/update forms:

<input name="name_en" value={node.name} /> <input name="name_fi" value={categoryTranslations[node.id]?.fi?.name ?? ""} />

Storefront Resolution

The storefront resolves translations at the service layer. Services accept an optional language parameter. When multi-language storefronts are needed, pass the detected language to service calls:

// Future: detect language from URL prefix, cookie, or Accept-Language header const product = await productService.getById(123, language);

Date Formatting

Date display locale is configured in src/lib/config/locale.ts:

export const DATE_LOCALE = "fi-FI"; // BCP 47 language tag

All dates in the admin and storefront use formatDate() and formatDateTime() from $lib/utils, which read this value.

Adding a New Language

  1. Add the language to LANGUAGES in src/lib/config/languages.ts
  2. No schema changes needed — translation tables already support any language code
  3. The admin UI automatically picks up the new language (language tabs render for all TRANSLATION_LANGUAGES)
  4. Add translations for existing records via the admin UI or programmatically via TranslationService

Best Practices

  1. Never hardcode "en" — always use DEFAULT_LANGUAGE from the config
  2. Default language fields go on entity tablesproducts.name, not in product_translations
  3. Non-default translations go in translation tables — managed via TranslationService
  4. Use translationsToMap() — converts DB rows to the map format used by translation UI
  5. Leave translations empty to fall back — the storefront uses entity table fields when no translation exists
Last updated on