Internationalization (I18n)

Namespaces

How VitNode partitions its message tree per plugin, how a page asks for only the branches it renders, and the limits a namespace has to satisfy.

A namespace is a dotted path into the merged message tree - core.global, @vitnode/blog.admin.article. It is the unit of two things at once: what useTranslations reads from, and what a page is allowed to download. The merged tree holds every installed plugin's copy, and no page should ship all of it, so a page names the branches it renders and gets exactly those.

Example

plugins/my-plugin/src/locales/en.json
{
  "my-plugin": {
    "home": { "title": "Hello World" },
    "admin": { "overview": { "title": "My plugin" } }
  }
}
const t = useTranslations('my-plugin.home')

Two namespaces exist there - my-plugin.home and my-plugin.admin.overview - and a page that renders the public one never downloads the admin one.

The tree

Everything sits under the id of the package that owns it. Core owns three branches; a plugin owns exactly one, named after itself.

NamespaceOwnerWhat is in it
core.globalcoreDesign-system strings every VitNode page needs
core.*coreAuth, files, search, content - the public site
admin.*coreThe AdminCP: admin.global, admin.user, admin.staff, …
@vitnode/blog.*@vitnode/blogEverything that plugin ships
{your-plugin}.*your pluginEverything yours ships

core.global is special only in that it is provided above every route, so any shared component can translate itself wherever it is mounted. Everything else is asked for.

The per-plugin prefix rule

Every top-level key a plugin ships is the plugin's own id. A key outside it collides with core and with every other plugin, so VitNode ignores it rather than letting two packages fight over one path.

plugins/my-plugin/src/locales/en.json
{
  "my-plugin": {
    "hello": "Hello World"
  },
  "world": "World"
}

Scoped ids work exactly the same way - @vitnode/blog is one path segment even though it contains a slash, so @vitnode/blog.admin is a legal two-segment namespace.

Permission labels are the one exception

A staff permission's label is a flat top-level key, not a branch: @vitnode/blog:posts:can_view. There is nothing to slice a namespace out of, so the AdminCP asks for those keys as if they were namespaces and has to chunk the request to stay under the limit below. If you are writing them, see Staff permissions.

Asking for a namespace

Declare the exact branches a plugin page renders as the route's messages. VitNode loads them with the route chunk, so public pages do not download a plugin’s AdminCP copy just because it exists.

plugins/my-plugin/src/routes.ts
import { definePluginRoutes, lazy, page } from '@vitnode/core/routing'

export const routes = definePluginRoutes([
  page('/reports', {
    component: lazy(() => import('./pages/reports-page')),
    messages: ['my-plugin.reports'],
  }),
])

A route inherits every namespace its layouts declare, so a shared frame can name them once for a whole subtree. The route module then calls useTranslations('my-plugin.reports'); if a key renders as its own name, confirm the route declared the matching namespace.

Limits

A namespace list reaches the server through a server function, which is a public POST endpoint once the app is built. So the rules are enforced rather than assumed, and the same rules validate a plugin's build-time declaration - one definition, in @vitnode/core/routing, so a route tree cannot accept something the server refuses.

Prop

Type

On top of the numbers: a namespace must be a non-empty string, must not contain an empty segment (core..global, a leading or trailing dot), and must not contain __proto__, constructor or prototype in any segment. All three are rejected outright rather than filtered away - a namespace containing one is not a namespace with a typo in it.

Import the constants if you need to budget against them:

import { MAX_NAMESPACES } from '@vitnode/core/tanstack/i18n'

Gotchas

Every string renders as its own key

The symptom of a namespace that was never asked for. Check that the route declares it in the plugin's routes.ts.

Over sixteen namespaces means chunking, not raising the cap

At most 16 namespaces may be requested. is a thrown error, not a truncation. If you genuinely need more - a screen rendering one flat key per permission - split the request the way the staff screens do and merge the results, rather than reaching for a bigger number.

Namespaces are per route, not per plugin

Name what the page renders. Declaring your plugin's whole tree on every route puts your AdminCP copy in the bundle of a public page nobody logged in is looking at.

A pendingComponent renders above its own provider

A layout route's pendingComponent renders in place of the layout, so it is outside the RouteMessages that layout mounts. Either keep it free of translated text or mount a provider inside it. A child route's pendingComponent is fine - it renders into the layout's <Outlet /> , which only exists once the provider has.

Next