Localization
PowerPortalsPro uses a JSON-based localization system for all user-facing text. This includes component labels, table and column names, view names, validation messages, and application-specific strings. The Blazor stack reads them through IStringLocalizer; the React stack reads them through the useT() hook. Both stacks consume the SAME JSON shape — strings authored once in app.en.json drive both stacks (and any shared resource files such as Dataverse-metadata-derived tables.* strings flow through to both automatically).
Localization Files
Localization is driven by JSON files placed in a localization directory in the server project. Files follow the naming convention name.{culture}.json (e.g. app.en.json, app.fr.json). The same JSON shape drives both the React stack (fetched at runtime by the localizer provider) and the Blazor stack (read at startup by IStringLocalizer).
// app.en.json
{
"app": {
"navigation": {
"home": "Home",
"contacts": "Contacts"
}
}
}
React
On the React side the localizer is auto-mounted by <PowerPortalsProProvider> — it includes a <DefaultLocalizerProvider> that fetches the active locale's bundle from /localizations/... on mount and on every locale change. Wrap the provider with a <LocaleProvider> when you want explicit locale-switching state (auto-detected from the URL path, ASP.NET culture cookie, or navigator.language by default). Strings the user sees during the brief window before the bundle resolves render as their raw keys; the provider swaps in the real strings as soon as the fetch lands.
// App.tsx — minimal bootstrap. PowerPortalsProProvider auto-mounts
// DefaultLocalizerProvider so useT() works in every descendant.
import { LocaleProvider, PowerPortalsProProvider } from '@powerportalspro/react';
export function App() {
return (
<LocaleProvider>
<PowerPortalsProProvider>
<Routes />
</PowerPortalsProProvider>
</LocaleProvider>
);
}
Blazor
Register the localization directories in the server's Program.cs via AddLocalizationDirectory. The framework reads them at startup, and they back every IStringLocalizer / IStringLocalizer<T> resolved through DI.
// Program.cs
builder.Services.AddPowerPortalsPro(options =>
{
options.AddLocalizationDirectory("localization");
options.AddLocalizationDirectory("_content/MyApp.Client/localization");
});
HTML Localization Files
For longer content such as email templates, you can use standalone HTML files instead of embedding HTML strings in JSON. The file name encodes the full localization key path and culture, using the format {key-path}.{culture}.html.
Each dot-separated segment of the file name maps to a level in the localization key hierarchy. The second-to-last segment is the culture code (e.g. en, fr). Place these files in the same localization directories registered with AddLocalizationDirectory.
For example, the following file structure:
localization/
app.en.json
app.fr.json
emails.signup-confirmation.body.en.html
emails.signup-confirmation.body.fr.html
emails.password-reset.body.en.html
The file emails.signup-confirmation.body.en.html maps to the localization key emails.signup-confirmation.body for the en culture. This is equivalent to having the HTML content as a string value in your JSON file at that key path.
// Retrieve the HTML content using the localization key
var emailBody = _localizer["emails.signup-confirmation.body"];
Retrieve the content using the same IStringLocalizer key that corresponds to the file name. The HTML content is returned as a localized string and can be rendered with ToMarkupString().
Table & Column Labels
Table and column display names, descriptions, and view labels are automatically resolved from the localization files using the convention tables.{tableName}.label, tables.{tableName}.columns.{columnName}.label, and tables.{tableName}.views.{viewId}.label.
// tables.en.json
{
"tables": {
"account": {
"label": "Account",
"collectionLabel": "Accounts",
"columns": {
"name": {
"label": "Account Name",
"description": "The name of the account."
}
},
"views": {
"00000000-0000-0000-0000-000000000001": {
"label": "Active Accounts"
}
}
}
}
}
Note
By default the framework loads metadata for every table in your Dataverse environment. For any portal that supports multiple languages, you should set
LocalizeAllAvailableTables = falseand register only the tables you actually use viaAddTableToLocalize— loading every table slows the startup warmup and fills the string cache with labels you never display (a cost paid per installed language). Register every table whose labels appear in the UI (grids, forms, subgrids, charts), since a displayed table that isn't localized renders its raw keys instead of labels.account,contact, andadx_externalidentityare included by default.
View Labels
View labels in the grid view selector dropdown are localized under tables.{tableName}.views.{viewId}.label, where {viewId} is the GUID of the Dataverse saved view (without braces, lowercase). This applies to both MainGrid and SubGrid view selectors.
// In tables.en.json
{
"tables": {
"account": {
"views": {
"00000000-0000-0000-00aa-000010001001": {
"label": "Active Accounts"
},
"00000000-0000-0000-00aa-000010001002": {
"label": "Inactive Accounts"
},
"91732ad4-b4fe-49ff-80cd-72b280eff088": {
"label": "All Contacts & Accounts"
}
}
}
}
}
Note
For custom views defined via
CustomViewDefinitions, the same convention applies — use the custom view's GUID as the key. If no localized label is found, the view's name from theGridViewDefinitionor Dataverse metadata is used as a fallback.
View Column Headers
Column headers displayed in grids are resolved using a fallback pattern. The system first looks for a view-specific column label at tables.{tableName}.views.{viewId}.columns.{columnName}.label. If not found, it falls back to the table-level column label at tables.{tableName}.columns.{columnName}.label. Tooltips follow the same pattern using .description instead of .label.
This allows you to override a column's header for a specific view without affecting its label in other views or editors.
// Override a column header for a specific view
{
"tables": {
"account": {
"views": {
"00000000-0000-0000-00aa-000010001001": {
"label": "Active Accounts",
"columns": {
"name": {
"label": "Company",
"description": "The company name for this account."
},
"contact.emailaddress1": {
"label": "Contact Email"
}
}
}
}
}
}
}
Note
For columns from linked entities (e.g.
contact.emailaddress1), the column name in the key uses the alias-prefixed format. If no view-specific label is found, the system constructs a label from the linked column name and its parent column label (e.g. "Email (Primary Contact)").
Choice (Option Set) Labels
Choice column option labels are localized differently depending on whether the option set is table-scoped or global.
Table-Scoped Choices
Table-scoped choices are localized under tables.{tableName}.choices.{choiceLogicalName}.values.{value}.label. The choice logical name is prefixed with the table name (e.g. account_accountcategorycode).
// In tables.en.json — table-scoped choices
{
"tables": {
"account": {
"choices": {
"account_accountcategorycode": {
"label": "Category",
"values": {
"1": { "label": "Preferred Customer" },
"2": { "label": "Standard" }
}
}
}
}
}
}
Global Choices
Global choices (option sets shared across multiple tables) are localized at the root level under choices.{choiceLogicalName}.values.{value}.label, outside of any table section.
// In tables.en.json — global choices (root level, outside "tables")
{
"choices": {
"powerpagelanguages": {
"label": "Preferred Language",
"values": {
"1033": { "label": "English" },
"1036": { "label": "French" },
"1031": { "label": "German" }
}
}
}
}
Note
The
ChoiceEditandMultiSelectChoiceEditcomponents automatically resolve choice labels from the correct location based on theIsGlobalproperty of the column metadata.
Injecting the Localizer
There are two ways to inject the string localizer:
IStringLocalizer— Access all localization keys globally. Use this for table labels, shared strings, or when you need theGetPrefixedLocalizermethod.IStringLocalizer<T>— Scoped to a specific component type. Keys are resolved relative to the component's namespace path in the JSON file (e.g.components.{Namespace}.{ComponentName}.{key}).
// Global localizer hook — fetches the active Localizer and returns its
// `t` function directly. Stable identity per render; safe in effect deps.
import { useT, usePrefixedT } from '@powerportalspro/react';
function MyComponent() {
const t = useT();
const label = t('app.navigation.home');
// Component-scoped lookups in React are sugar over a fixed prefix. The
// bundle still carries `components.<FullTypeName>.<key>` shape —
// pass that full namespace to usePrefixedT and call with just the key.
const componentT = usePrefixedT(
'components.MyApp.Pages.MyComponent',
);
const title = componentT('title'); // → components.MyApp.Pages.MyComponent.title
return <h1>{title}</h1>;
}// Global localizer — access any key
[Inject]
private IStringLocalizer _localizer { get; set; } = null!;
// Component-scoped localizer — keys resolve relative to the component's namespace
[Inject]
private IStringLocalizer<MyComponent> _localizer { get; set; } = null!;Component-Scoped Keys
When using IStringLocalizer<T>, keys are resolved based on the component's namespace and class name. For example, a component at Pages.Editors.TextEdit.TextEditDemoPage resolves keys from components.{AssemblyName}.Pages.Editors.TextEdit.TextEditDemoPage.{key} in the JSON.
// In app.en.json — keys for a component at Pages/Editors/TextEdit/TextEditDemoPage
{
"components": {
"MyApp.Client": {
"Pages": {
"Editors": {
"TextEdit": {
"TextEditDemoPage": {
"title": "TextEdit",
"description": "A single-line text input."
}
}
}
}
}
}
}
// usePrefixedT pins the full namespace ONCE so call sites only repeat
// the trailing key. Resolves against
// `components.MyApp.Client.Pages.Editors.TextEdit.TextEditDemoPage.title`
// in the bundle.
import { usePrefixedT } from '@powerportalspro/react';
const STRINGS_BASE =
'components.MyApp.Client.Pages.Editors.TextEdit.TextEditDemoPage';
export function TextEditDemoPage() {
const t = usePrefixedT(STRINGS_BASE);
return (
<>
<h1>{t('title')}</h1>
<p dangerouslySetInnerHTML={{ __html: t('description') }} />
</>
);
}<!-- In the component -->
@inject IStringLocalizer<TextEditDemoPage> _localizer
<h1>@_localizer["title"]</h1>
<p>@_localizer["description"].ToMarkupString()</p>Prefixed Localizer
Use GetPrefixedLocalizer to create a sub-localizer that automatically prepends a prefix to all key lookups. This is useful for avoiding repetitive key prefixes in components that use many keys from the same section.
// usePrefixedT(prefix) returns a t-function with every key auto-prepended
// with `${prefix}.`. Memoized per prefix value so it's stable across
// renders (safe in effect deps).
import { usePrefixedT } from '@powerportalspro/react';
function Nav() {
const t = usePrefixedT('app.navigation');
const home = t('home'); // → app.navigation.home
const contacts = t('contacts'); // → app.navigation.contacts
return /* ... */;
}// Without prefix — repetitive
var home = _localizer["app.navigation.home"];
var contacts = _localizer["app.navigation.contacts"];
// With prefix — cleaner
var navLocalizer = _localizer.GetPrefixedLocalizer("app.navigation");
var home = navLocalizer["home"];
var contacts = navLocalizer["contacts"];HTML in Localized Strings
Localized strings can contain HTML markup. Use the ToMarkupString() extension method to render them as MarkupString in Razor templates.
// dangerouslySetInnerHTML renders the localized string as HTML — the
// value is trusted markup since you authored it in the bundle.
<p dangerouslySetInnerHTML={{ __html: t('description') }} /><!-- Renders HTML markup from localized string -->
<p>@_localizer["description"].ToMarkupString()</p>Finding the Best Match
Use FindLocalizedString to look up the first matching key from a list of candidates. This is useful for fallback patterns where you want to try a specific key first, then fall back to a more general one.
// `t()` returns the raw key when a string is missing, so `t(key) !== key`
// is the "did this key resolve?" check you build a fallback walk on.
import { useT } from '@powerportalspro/react';
function resolve(t: ReturnType<typeof useT>, ...candidates: string[]): string {
for (const key of candidates) {
const value = t(key);
if (value !== key) return value;
}
return candidates[candidates.length - 1];
}
const t = useT();
const label = resolve(
t,
`tables.${tableName}.columns.${columnName}.label`,
`tables.${tableName}.label`,
);// Try specific key first, fall back to general
var label = _localizer.FindLocalizedString(
$"tables.{tableName}.columns.{columnName}.label",
$"tables.{tableName}.label");On the React side, useT() returns the raw key when a string is missing — t(key) !== key is the "did this key resolve?" check you build a fallback walk on. Wrap the pattern in a helper when you need it at multiple call sites.
Argument Interpolation
Strings can include positional {0}, {1}, … placeholders that are substituted at lookup time. Blazor passes args as a params array to IStringLocalizer; React passes them as the second argument to t(). Optional format specifiers inside a placeholder ({0:N0}, {1:yyyy-MM-dd}) are honored by Blazor's String.Format pipeline only; for React, pre-format with Intl.NumberFormat / Intl.DateTimeFormat before passing the value in.
// In app.en.json
{
"app": {
"welcome": "Welcome back, {0}! You have {1} unread messages."
}
}
// Args passed as the second argument to t() — positional, indexed by `{0}`.
const greeting = t('app.welcome', [userName, unreadCount]);// Args passed via params array — IStringLocalizer's indexer accepts them.
var greeting = _localizer["app.welcome", userName, unreadCount];Eager Loading (React)
On a cache miss the React stack lazily fetches the owning per-resource bundle (one debounced batch per ~75 ms) and re-renders the consuming component when the strings land. To eliminate the brief flash of raw keys, declare the prefixes a page will need up front via useLocalization([...]), or hold off rendering until they resolve via the higher-level <LocalizationBoundary> wrapper. See the LocalizationBoundary page for the full pattern.
// Eager-load a page's per-resource bundles up front to avoid a
// flash of raw keys when navigating in. Tokens that are already
// fetched (or in flight) are silently deduped, so calling from two
// components on the same page produces one round-trip total.
import { useLocalization } from '@powerportalspro/react';
function AccountFormPage() {
useLocalization(['tables.account', 'tables.contact']);
return /* ...form... */;
}
IStringLocalizer Interface
Properties
Name | Type | Default | Description |
|---|---|---|---|
Item | LocalizedString |
ItemMethods
Name | Parameters | Type | Description |
|---|---|---|---|
FindLocalizedString | string[] keys | LocalizedString | Returns the first valid match based on the provided keys. |
GetAllStrings | bool includeParentCultures | IEnumerable<LocalizedString> | |
GetPrefixedLocalizer | string prefix | IPrefixedStringLocalizer | Returns a new Localization.IPrefixedStringLocalizer that prepends the given prefix to all key lookups. |
FindLocalizedStringGetAllStringsGetPrefixedLocalizerLocalization.IPrefixedStringLocalizer that prepends the given prefix to all key lookups.