Add a route to the Next.js app under src/frontend/web/app/[lang] — server page component, _components client component, locale-prefixed routing, the three access layers (proxy.ts sign-in check, auth-urls.ts permissions, tenant-scope routing), i18n keys, nav-items and searchable-items registration. Use for any new screen that is not a full CRUD set.
Install
npx skillscat add ma496/easy-for-net/frontend-page Install via the SkillsCat registry.
Adding a page
Route layout
Every route is locale-prefixed: app/[lang]/…. There are no public pages — the dashboard is the
home page (/). Route groups:
(auth)— signin, signup, profile, password flows,select-tenant,unauthorized(app)— the signed-in app shell (sidebar, header) around the dashboard ((app)/page.tsx) and
every other screen, adding no URL segment ((app)/settingsis/settings); nested route groups
such as(app)/(identity)and(app)/(tenancy)group a feature's screens the same way
A screen is a folder with page.tsx plus a sibling _components/ folder holding the interactive
client parts. Dynamic segments are folders, and a screen about one record puts the id first:[id]/update/page.tsx, [id]/detail/page.tsx (/tenants/{id}/detail). A client
component shared by several screens of one group sits in the group's own _components/
((app)/(tenancy)/_components/feature-value-editor.tsx).
The page component (server)
page.tsx is a server component. It awaits params, resolves the title withgetServerTranslation, and renders the client component inside PageContent:
import { getServerTranslation } from '@/i18n'
import { TenantDetailView } from './_components/tenant-detail-view'
import { PageContent } from '@/components/layouts'
/** Props for the tenant detail page, providing the route lang segment and the id of the tenant being shown. */
interface TenantDetailPageProps {
params: Promise<{
lang: string
id: string
}>
}
/** Server-rendered tenant detail page that resolves the localized title and renders the detail view. */
const TenantDetail = async ({ params }: TenantDetailPageProps) => {
const { lang, id } = await params
const title = (await getServerTranslation(lang, 'page.tenants.detail.title')) || 'Tenant Detail'
return (
<PageContent title={title} plain>
<TenantDetailView tenantId={id} />
</PageContent>
)
}
export default TenantDetailA route without a dynamic segment types params: Promise<{ lang: string }> and passes nothing down.
PageContent is the page frame: a header with title, an optional description line andactions (end-aligned controls such as a primary "create" button), then the body on one card.innerClassName styles that card — constrain form width with it (max-w-3xl for most
create/update forms); list and detail screens leave it full width. plain drops the card, for a
page that lays out several cards of its own (a detail or settings screen). Colors follow the
token rules in the ui-component skill.
The client component
_components/<kebab-name>.tsx, first line 'use client', named export, JSDoc above the component.
Everything interactive lives here: RTK Query hooks, Formik forms, tables, modals. Server pages
never call the API directly.
Common imports:
import { useTranslation } from '@/i18n'
import { useLocalizedRouter, useTableUrlState } from '@/hooks'
import { Button, ApiErrorMessages, Loader, LocalizedLink } from '@/components/ui'
import { FormInput, FormCheckbox } from '@/components/ui/form'
import { apiErrorAlert, successToast, isAllowed } from '@/lib/utils'
import { useAppSelector } from '@/store/hooks'
import { useTenantGetQuery } from '@/store/api/tenancy'
import { Allow } from '@/allow'Anything that loads data renders in this order: isLoading → <Loader />, error →<ApiErrorMessages error={error} />, no data → a translated "not found" line, then the content —
each state centred in a flex items-center justify-center wrapper (on its own panel when the page
is plain).
Navigation must stay locale-aware — use useLocalizedRouter() instead of next/navigation's
router, and <LocalizedLink href="/users"> instead of next/link. Pass unprefixed
paths; the helpers add the locale segment (router.localize(href) gives the prefixed path for a
full page load).
Access: sign-in, permissions, tenant scope
Three layers decide whether a screen opens; register the route with each that applies.
proxy.ts(Next 16's renamed middleware) negotiates the locale and checks only that a
session cookie exists: every page but the anonymous screensanonymousUrlsinauth-urls.ts
names redirects to/signin?redirect=…without one, so a new screen is guarded with no
registration at all. A new screen that must open signed out joinsanonymousUrls. It does not check permissions.auth-urls.tsnames the permissions a route needs.App.tsx(the client root guard) matches
the locale-stripped path withgetMatchedAuthUrland sends a caller lacking them to/unauthorized; the sidebar and global search hide the same entries.{ url: '/tenants/{id}/detail', permissions: [Allow.Tenant_Detail] },{id}in a url acts as a wildcard segment. Two entries must not match the same pathname — the
matcher throws (auth-urls.test.tspins this). A screen that depends on the tenant's plan needs
nothing extra here: the API gates its permission withRequireFeatures, so the permission is
simply absent from a session whose plan withholds it — seepermissionsandfeature-management.Tenant scope —
lib/utils/tenant-routing.tsdecides which scope a path belongs to, andisPathAvailable(user, path)is whatApp.tsx, the sidebar and search consult. Every screen but
account self-service, the tenant picker and the anonymous screens is tenant-only by default:
a platform account acting in no tenant is sent to the dashboard (/)
and never sees it in the menu. Add the path prefix toplatformAccessiblePathPrefixesif the
screen also answers in platform scope, or toplatformOnlyPathPrefixesif it belongs to the
platform alone (a caller acting inside a tenant is then kept out) — or toplatformOnlyPathPatternsfor a platform-only screen about one record, whose id comes before
the screen so no prefix can name it (/tenants/{id}/features). See themulti-tenancyskill.
In-page, gate actions with isAllowed(authState, [Allow.X]) — see the permissions skill. Reach foruseFeature(FeatureNames.X) (@/hooks, @/feature-names) only where there is no permission to gate
on, such as a numeric limit or an upsell panel.
Translations
Add every string to the API's Features/Localization/Core/Resources/en.json and to every other<code>.json beside it (the backend's LocalizationResourceStoreTests fail when their key sets
differ). Follow
the existing namespaces: page.<area>.* (or page.<area>.<screen>.*), form.label.*,form.placeholder.*, validation.*, table.*, navigation.*, search.*, error.server.*,common.*.
Server components use (await getServerTranslation(lang, key)) || 'English'; client components useconst { t } = useTranslation() and t('key', { min: 3 }) || 'English' (placeholders are ${min} in the JSON;
the default after || is the key's en.json value, which renders when the key is not served).
Both come from @/i18n, never a subpath. The localization skill covers the third (non-component)
translator, overrides and adding a language.
Register the destination
nav-items.ts— sidebar entry. Items are either aNavItemor aNavItemGroup({ title, items }).titleis an i18n key,urlis unprefixed,iconcomes fromlucide-react, and detail routes are
listed as children withshow: falseso they highlight the parent (and feed the breadcrumbs)
without appearing in the menu. There is no permission field: the sidebar drops an item whoseurl
exactly matches anauth-urls.tsentry the caller fails or thatisPathAvailablerejects, and a
parent whose children are all dropped.searchable-items.ts— global search entry ({ title: 'search.users', url: '/users' }),
filtered the same way. Routes with an{id}segment are not listed.
Checklist
-
app/[lang]/<group>/<route>/page.tsx(server, default export) -
_components/<name>.tsx('use client', named export) -
auth-urls.tsentry if the route needs a permission - Scope registered in
lib/utils/tenant-routing.tsif the screen is not tenant-only - Keys in every backend resource file (
Features/Localization/Core/Resources/*.json) -
nav-items.ts+searchable-items.ts -
npm run lint,npx tsc --noEmit,npm run testandnpm run buildpass