v1.9.20
Internationalization (i18n)
Canopy handles multiple languages as of version v1.9.0.
The default configuration supports a single English locale, but you can easily customize this, and define additional locales by declaring them in canopy.yml while also extending content under content/<lang>/. The builder automatically wires up localized pages, UI strings, and navigation based on your file structure. You may also redefine the default locale entirely, so the system is flexible enough to support non-English primary languages as well.
Setup checklist
- Add locales to
canopy.yml. - Organize content into language folders.
- Create locale-specific
locale.ymlandnavigation.ymlfiles as needed. - Customize the language toggle in the header or footer.
Define locales
Declare supported languages in the canopy.yml file. Provide at least one locale; when default is omitted, the first entry becomes the default language. Use BCP 47 tags (es, pt, pt-BR, etc.) when defining lang values so future automation can derive the correct <html lang> attributes. This also allows for rendering of IIIF metadata that uses the same codes for internationalization support.
title: Site titlelocales: - lang: en label: English default: true - lang: es label: EspañolNon-English defaults
If you want to use a different default language, simply mark that entry as default: true. For example, to make Spanish the default language with English as a secondary locale, your canopy.yml would look like this:
title: Título del sitiolocales: - lang: es label: Español default: true - lang: en label: EnglishYou may also drop English entirely when another locale should be the primary language. You do not need to declare more than one locale in this case. To do this, simply define a language and mark it as default:
title: Título del sitiolocales: - lang: es label: Español default: trueOrganize content by locale
In Canopy, localization mirrors the file tree of the content/ directory. The default locale lives directly under content/ while a non-default locale would live under content/<lang>/. This structure requires careful organization while maintaining clear separation between languages.
For example, if you have an English homepage at content/index.mdx and an About page at content/about.mdx, the Spanish versions would be content/es/index.mdx and content/es/sobre.mdx. A possible English and Spanish setup might look like this:
├── content/│ ├── _app.mdx # global wrapper for all pages│ ├── index.mdx # English homepage│ ├── about.mdx # English about page│ ├── locale.yml # English language strings│ ├── navigation.yml # English navigation│ └── es/│ ├── index.mdx # Spanish homepage│ ├── sobre.mdx # Spanish about page│ ├── locale.yml # Spanish language strings│ └── navigation.yml # Spanish navigationLocale interface and navigation
At each locale level, you may also choose to customize the user interface and navigation. Each locale can have its own locale.yml for UI strings and navigation.yml for menu items. When a localized file is missing, the builder falls back to the default locale’s version, so you only need to override what is necessary. This allows you to maintain a consistent structure while providing a fully localized experience for users in different languages.
locale.yml
The default content/locale.yml defines strings for search labels, filter copy, error notices, and the language toggle. Create content/<lang>/locale.yml to override keys. Update routes for search and works if you need translated slugs. If any key is undefined, entries continue to inherit from the English source. If content/locale.yml and/or content/<lang>/locale.yml is missing, the user interface will automatically fall back to hardcoded English terms.
Route slugs
Each locale file can override the default routes so slugs like /search and /works can be translated per language. When a locale omits a route key, the builder inherits the slug from the default locale.
routes: search: /es/buscar works: /es/obrasEnglish fallback snapshot
When content/locale.yml is absent, Canopy reads packages/app/lib/default-locale.js to keep rendering English copy. This file is generated directly from your checked-in content/locale.yml, so updating that single YAML file keeps both sources synchronized. After editing the English locale, run npm run sync:locale (which executes packages/helpers/locales/sync-default-locale.js) to refresh the baked-in fallback before committing.
Example base locale (content/locale.yml):
routes: search: /search works: /workscommon: actions: open: "Open" close: "Close" clear: "Clear" clear_all: "Clear all" done: "Done" show: "Show" hide: "Hide" nouns: search: "Search" filters: "Filters" results: "results" values: "values" types: "types" items: "items" navigation: "navigation" section_navigation: "section navigation" content_navigation: "content navigation" gallery: "gallery" gallery_thumbnails: "gallery thumbnails" map: "map" map_data: "map data" map_locations: "map locations" item_label: "Item" details: "details" breadcrumb: "Breadcrumb" home: "Home" types: work: "Works" page: "Pages" docs: "Docs" statuses: loading: "Loading…" empty_short: "No {content}" empty_detail: "No {content} available." unavailable: "{content} unavailable." unavailable_detail: "{content} is unavailable." failed: "Unable to load {content}." loading_content: "Loading {content}…" summary_content: "Showing {shown} of {total} {content}" search_summary: 'Found {shown} of {total} in {type} for "{query}"' no_matches: "No matches found." phrases: placeholder_search: "Search…" results_label: "Search results" open_content: "Open {content}" close_content: "Close {content}" show_content: "Show {content}" hide_content: "Hide {content}" nav_label: "{content} navigation" search_content: "Search {content}" filter_values: "Filter {content} values" clear_content_search: "Clear {content} search" none_applied: "No {content} applied" scroll_direction_content: "Scroll {direction} through {content}" step_content: "{direction} {content}" item_numbered: "{content} {index}" content_key: "{content} key" directions: left: "left" right: "right" previous: "Previous" next: "Next" misc: referenced_by: "Referenced by" on_this_page: "On this page"Example translated locale (content/es/locale.yml):
routes: search: /es/buscar works: /es/obrascommon: actions: open: "Abrir" close: "Cerrar" clear: "Borrar" clear_all: "Borrar todo" done: "Listo" show: "Mostrar" hide: "Ocultar" nouns: search: "Buscar" filters: "Filtros" results: "resultados" values: "valores" types: "tipos" items: "elementos" navigation: "navegación" section_navigation: "navegación de sección" content_navigation: "navegación de contenido" gallery: "galería" gallery_thumbnails: "miniaturas de galería" map: "mapa" map_data: "datos del mapa" map_locations: "lugares del mapa" item_label: "Elemento" details: "detalles" breadcrumb: "Miga de pan" home: "Inicio" types: work: "Obras" page: "Páginas" docs: "Docs" statuses: loading: "Cargando…" empty_short: "Sin {content}" empty_detail: "No hay {content} disponible." unavailable: "{content} no disponible." unavailable_detail: "{content} no está disponible." failed: "No se puede cargar {content}." loading_content: "Cargando {content}…" summary_content: "Mostrando {shown} de {total} {content}" search_summary: 'Se encontraron {shown} de {total} en {type} para "{query}"' no_matches: "Sin coincidencias." phrases: placeholder_search: "Buscar…" results_label: "Resultados de búsqueda" open_content: "Abrir {content}" close_content: "Cerrar {content}" show_content: "Mostrar {content}" hide_content: "Ocultar {content}" nav_label: "Navegación de {content}" search_content: "Buscar {content}" filter_values: "Filtrar valores de {content}" clear_content_search: "Borrar búsqueda de {content}" none_applied: "Sin {content} aplicados" scroll_direction_content: "Desplazar {direction} en {content}" step_content: "{direction} {content}" item_numbered: "{content} {index}" content_key: "Clave de {content}" directions: left: "izquierda" right: "derecha" previous: "Anterior" next: "Siguiente" misc: referenced_by: "Referenciado por" on_this_page: "En esta página"navigation.yml
Primary navigation links live in content/navigation.yml. Add content/<lang>/navigation.yml to translate menu labels or point to locale-specific pages. When a localized navigation file is missing, the default entries at content/navigation.yml are reused.
navigation: - href: /search label: Works - href: /about label: Aboutnavigation: - href: /es/buscar label: Obras - href: /es/sobre label: SobreToggling languages
As a default, the Canopy Header automatically renders a language switcher whenever canopy.yml → locales is defined. You may customize this by passing languageToggle={false} to hide the built-in control and dropping <LanguageToggle /> anywhere in your MDX content. The toggle automatically picks up the current page context and site configuration to link to the correct localized version of each page.
<> <CanopyHeader languageToggle={false} /> <main>{children}</main> <CanopyFooter> <p>Copyright 2025 Canopy IIIF, MIT License</p> </CanopyFooter></>Insert the <LanguageToggle /> component where you want the control to appear, such as in the footer.
<> <CanopyHeader languageToggle={false} /> <main>{children}</main> <CanopyFooter> <LanguageToggle /> <p>Copyright 2025 Canopy IIIF, MIT License</p> </CanopyFooter></>You can also customize the control’s appearance by passing a variant prop:
<LanguageToggle variant="list" />