Architecture & Routing
How the app boots, how routes are defined, and how state/persistence is organized.
Overview
The application has three architectural pillars:
- A provider tree (
src/app/[locale]/layout.tsx+providers.tsx) that supplies layout config, motion config, and toasts. - File-system routing (
src/app/[locale]/**), with per-route presentation declared insrc/platform/routes.ts. - A file-per-feature data layer (
src/data/*.ts) with a consistent seed + load/save/clear persistence pattern.
Understanding these three makes the rest of the codebase predictable.
The provider tree
The tree is split across two files, because the outer one runs on the server and the providers do not.
src/app/[locale]/layout.tsx owns <html>, the fonts and the translation provider; providers.tsx is the
client boundary below it:
// src/app/[locale]/layout.tsx (shape) — a Server Component
<html lang={locale} className={fontVars} data-skin={prefs.skin}>
<body>
<NextIntlClientProvider>
<Providers prefs={prefs}>{children}</Providers>
</NextIntlClientProvider>
</body>
</html>
// src/app/[locale]/providers.tsx (shape) — 'use client'
<LayoutProvider initialConfig={prefs.config} initialSkin={prefs.skin}>
<MotionProvider>
<ToastProvider>
{children}
<AppSplash /> {/* branded first-load splash, mounted after hydration */}
<Toaster /> {/* mounted above the shell so toasts survive navigation */}
</ToastProvider>
</MotionProvider>
</LayoutProvider>The shell itself is added by the (app) route group's layout, so the auth screens — which live in (auth)
— get the providers without the sidebar and header.
Key point: <Toaster /> lives above the shell, not inside AppLayout, so toasts persist across
navigation (e.g. a sign-out toast that outlives the route change). ChatProvider is mounted lower, inside
AppLayout, because the chat widgets only exist within the shell.
Info
⚠️ StrictMode gotcha. In development, StrictMode mounts → unmounts → remounts each component. Any
"is-mounted" ref must be set true on mount (not only false on cleanup), or it stays stuck false. See
Panel for a real example of this bug and its fix.
First paint (no flash)
The saved theme, skin and layout must be applied before anything renders, or the first frame shows the default design and then snaps to the user's.
The server does it. localStorage is invisible to a server, so the four values the first paint depends on
(theme, design_skin, oled_dark, layout_config) are mirrored into cookies when the user changes
them, and src/app/serverPrefs.ts reads them back on the next request:
<html>is rendered with.dark,data-skinanddata-oledalready correct;LayoutProviderreceives the saved config asinitialConfig, so the shell'sdata-*attributes match on the very first render — no post-hydration jump;- every cookie value is validated against the app's own allowlists before it reaches an attribute, because cookies are user-controlled input.
One case cannot be answered on the server: theme: 'system' depends on the visitor's OS, which no request
header reliably reports. That case alone keeps a tiny inline script. Because cookies are read per request,
these routes render dynamically — which is what a per-visitor admin shell wants.
Either way the attributes are in place before first paint, so the token system (see Design Tokens & Dark Mode) resolves the right colors immediately.
The routing model
Routes are file-system based under src/app/[locale]/. Every URL is a directory, and every page is a
thin Server Component that owns its metadata and renders the shared page component:
// src/app/[locale]/(app)/ui/panels/page.tsx (shape)
export async function generateMetadata({params}) {
const {locale} = await params
const t = await getTranslations({locale, namespace: 'nav'})
return {title: t('panels')}
}
export default async function Page({params}) {
const {locale} = await params
setRequestLocale(locale)
return <PanelsPage />
}The wrapper is a Server Component for one reason: a Client Component cannot export metadata. The page
component it renders is a client component, as the whole UI is.
Two route groups organise the tree without adding URL segments:
(app)— everything inside the shell. Itslayout.tsxrenders the sidebar/header chrome, and itstemplate.tsxsupplies the route-change transition (atemplateremounts per navigation, which is what the entrance animation needs).(auth)— the seven auth screens, which own the whole viewport (see next section).
The / index is (app)/page.tsx, which renders HomeRoute — that component picks <BentoDashboard/>,
<ConsoleDashboard/> or <CrmDashboard/> from the active skin, so the home page follows the design.
Shell metadata that has no home in the file system lives in src/platform/routes.ts: routePaths and
authPaths (so the shell can tell an unbuilt menu leaf from a real 404) and routePresentation (the
per-route flags below).
To add a real page
Standalone auth routes
Auth pages render standalone — outside the shell (no sidebar/header/footer), so they are declared apart from the in-shell routes. The seven screens themselves are covered in Authentication.
They live in the (auth) route group, whose layout.tsx renders nothing but its children:
src/app/[locale]/(auth)/
layout.tsx renders {children} — no shell
login/page.tsx
login-v2/page.tsx
register/page.tsx
register-v2/page.tsx
forgot-password/page.tsx
reset-password/page.tsx
lock-screen/page.tsx
The group name is in parentheses, so it organises files without appearing in the URL — these are still
/auth/login and so on. Paths must match the menu (src/data/menu.ts), and they are listed in
authPaths (src/platform/routes.ts) so the shell knows they exist.
These are also the routes the optional guard leaves public: with AUTH_REQUIRED=true, everything else
redirects here.
Per-route presentation flags
Presentation flags live in routePresentation (src/platform/routes.ts) and are surfaced through
useRouteLayout() (src/layout/routeLayout.ts).
Either way the shell can force a layout for specific routes without touching the user's saved config. Examples in the codebase:
/apps/chat→fullBleed + headerOnly + fixedFooter(the chat page owns the whole viewport and supplies its own rail)./apps/pricing→fullBleed + headerOnly + hideWidgets(a marketing landing page —hideWidgetssuppresses the chat rail/bubble there)./apps/scrumboard,/apps/calendar,/apps/contacts→fullBleed.- The 12
/email/*template routes and the 3 immersive/error/*pages →fullBleed.
useRouteLayout() returns {forceHeaderOnly, forceFixedFooter, forceHideWidgets, fullBleed}, consumed by
AppLayout and Header.
Full-bleed pages do not render <PageHeader> (they call useDocumentTitle directly) and size themselves
with calc(100dvh - var(--app-header-height) - <footer>). See
Layout System.
Placeholders & the catch-all
The placeholder mechanism keeps the menu and the router from drifting.
A catch-all segment does it. (app)/[...slug]/page.tsx receives any URL no real page matched — static
routes always win over a catch-all — and looks it up in the menu:
// src/app/[locale]/(app)/[...slug]/page.tsx (shape)
const key = findLeafKey(`/${slug.join('/')}`)
if (!key) notFound() // not a menu leaf → a genuine 404
return <Placeholder titleKey={`nav:${key}`} /> // a menu leaf with no page yetAs of today, every menu leaf resolves to a real page — the former roadmap areas (Authentication, Email
Templates, Error Pages, Icons, Settings, Editors, Widgets, FAQ, Invoice, Cookies) have all been built. The
mechanism stays as a safety net: add a menu leaf before its route exists and it renders a friendly titled
"coming soon" screen (src/pages/Placeholder.tsx) instead of breaking navigation. See the
feature matrix.
Anything that isn't a menu leaf calls notFound(), which renders not-found.tsx — the same immersive 404
template.
That designed 404 is also browsable at /error/404 — see Error Pages.
The data layer
Feature data lives in src/data/<feature>.ts, each following the same shape:
// src/data/<feature>.ts (pattern)
export const STORAGE_KEY = 'feature-state-v1'
export interface FeatureState { /* … */ }
export const seed: FeatureState = { /* … */ }
export function loadX(): FeatureState { /* read localStorage, fall back to seed */ }
export function saveX(state: FeatureState): void { /* write localStorage */ }
export function clearX(): void { /* remove the key */ }Modules that follow this: chat.ts (chat-state-v2), calendar.ts (calendar-state-v2), scrumboard.ts
(scrumboard-state-v1), contacts.ts (contacts-state-v1), invoice.ts (invoice-state-v1), cookies.ts
(cookie-consent-v1). Static (non-persisted) data: menu.ts, navBadges.ts, apps.ts, quickCreate.ts,
megaMenu.ts, user.ts, notifications.ts, pricing.ts, settings.ts, faq.ts, countries.ts, and the
emailTemplates/ folder (meta.ts descriptors + one HTML module per template).
Feature view-models are hooks. Large app pages keep state/logic in a co-located hook and stay
render-only: useBoard (scrumboard), useCalendar (calendar), useApplicants (contacts),
useChatPanes/useListMode (chat). Follow this pattern for new complex pages.
src/platform/ — swappable integrations
A small set of modules that wrap the libraries the UI would otherwise depend on directly:
| Module | Wraps | Why it exists |
|---|---|---|
nav.tsx | The router — Link, NavLink, useNavigate, usePathname, useSearchParams | Router choice stays in one file |
i18n.ts | The i18n library — useTranslation, Trans, TFunction | i18n library choice stays in one file |
env.ts | Build-time environment values — {apiBaseUrl, gtmId, basePath} | One place to see everything the app reads from the environment |
auth.ts | The submit flow — useAuthSubmit, signOut | The one file to change when attaching a real backend |
routes.ts | Route metadata — routePaths, authPaths, routePresentation | The shell reads this rather than the route definitions |
image.tsx | <Img/> | A single place to switch image rendering |
clientOnly.tsx | Deferred rendering | Marks components that must not render before the browser does |
Why the indirection. Every component imports @/platform/nav rather than the routing library itself, so
swapping routers is one file rather than a search-and-replace across the ~35 that navigate. The same goes for
the i18n library and for attaching a real auth backend. If you have no intention of swapping any of it,
you can collapse a module to a direct re-export — nav.tsx is already little more than that — and
nothing else has to change.
A note on <Img/>: it renders next/image, which needs the intrinsic width/height to reserve space
and avoid layout shift. Pass them whenever you know them; without them the adapter falls back to a plain
<img> rather than guessing an aspect ratio.
And on clientOnly: it defers a component to the client entirely (ssr: false). That is the right tool
for a page whose content comes from browser storage, or one that loads a library touching window at import
time — a server has nothing truthful to render for either.
Persistence & Reset
Every persisted key is registered in src/lib/appStorage.ts:
// src/lib/appStorage.ts (shape)
export const APP_LOCAL_KEYS = [
'layout_config', 'design_skin', 'oled_dark', 'theme', 'language',
'nav-favorites', 'user-status', 'recent-routes',
'chat-rail-collapsed', 'chat-state-v2', 'chat-list-mode',
'emoji-mart.frequently', 'emoji-mart.last',
'contacts-state-v1', 'scrumboard-state-v1', 'calendar-state-v2',
'invoice-state-v1', 'cookie-consent-v1',
// + a few legacy keys (chat-state-v1, calendar-state-v1, contacts-view-mode) kept so
// Reset also cleans up state left behind by older versions
] as const
export const APP_SESSION_KEYS = ['app-splash-shown'] as const
export function clearAppStorage() { /* removes every key above — but NOT auth_token */ }"Reset to defaults" (in the Customizer + Layout Settings) calls clearAppStorage() then reloads, so every
store re-hydrates from defaults + seed while the user stays signed in (auth_token is deliberately preserved).
Info
🔑 Rule: any time you add a persisted key, add it to APP_LOCAL_KEYS/APP_SESSION_KEYS so Reset wipes
it. Prefer an exported STORAGE_KEY constant over an inline string.
Four of those keys (theme, design_skin, oled_dark, layout_config) are additionally mirrored into
cookies so the server can render the right design on the first request — see
First paint. Write them through persist() rather than localStorage.setItem, and
Reset expires the cookies too (otherwise a reload would restore the design the user just reset).
Best practices
- Add real pages as a directory with a
page.tsx, and keepsrc/platform/routes.tsin step so the shell knows the route exists. - Keep page components render-only; push state into a co-located
useXhook when it grows. - Force per-route layout with presentation flags, not by mutating
LayoutContext. - Register every new
localStorage/sessionStoragekey inappStorage.ts.
Troubleshooting
| Symptom | Cause / fix |
|---|---|
| A menu item shows "coming soon" | No page.tsx matches its to, so the catch-all served a placeholder — add the route directory. |
| A stuck boolean ref in dev only | StrictMode double-mount — set the ref true on mount, not just false on cleanup. |
| Reset didn't clear my new feature's state | The key isn't in appStorage.ts. Add it. |
| Full-bleed page has a scroll gap | Size it with calc(100dvh - var(--app-header-height) - <footer>); subtract the footer only when fixedFooter. |
FAQ
Why is / special? The home dashboard changes with the active skin (Bento/Console/CRM), so it resolves
the dashboard at render time instead of being a fixed page.
Can I force a page to be full-screen? Yes — give it fullBleed (and optionally
headerOnly/fixedFooter/hideWidgets).
Add an entry to routePresentation in src/platform/routes.ts.
This is presentation-only and doesn't change the saved customizer config.
How do I add a page that renders without the shell entirely?
Put it in the (auth) route group, whose layout renders no shell — that's how the seven auth screens work.
For a page that keeps the header but drops the sidebar, prefer headerOnly instead.
Notes for designers & content editors
- The sidebar reflects
src/data/menu.ts. Reordering or renaming sections there (plus the matchingnav:i18n key) restructures navigation without touching pages. - Every current menu leaf opens a real page; a "coming soon" screen only appears for a newly added menu leaf whose page hasn't been built yet.
Related
Was this page helpful?
