PVR Tech Studio
Dev architecture

Architecture & Routing

How the app boots, how routes are defined, and how state/persistence is organized.

9 min read
Updated August 13, 2026

Overview

The application has three architectural pillars:

  1. A provider tree (src/app/[locale]/layout.tsx + providers.tsx) that supplies layout config, motion config, and toasts.
  2. File-system routing (src/app/[locale]/**), with per-route presentation declared in src/platform/routes.ts.
  3. 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.

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-skin and data-oled already correct;
  • LayoutProvider receives the saved config as initialConfig, so the shell's data-* 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. Its layout.tsx renders the sidebar/header chrome, and its template.tsx supplies the route-change transition (a template remounts 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).

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 — hideWidgets suppresses 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 yet

As 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:

ModuleWrapsWhy it exists
nav.tsxThe router — Link, NavLink, useNavigate, usePathname, useSearchParamsRouter choice stays in one file
i18n.tsThe i18n library — useTranslation, Trans, TFunctioni18n library choice stays in one file
env.tsBuild-time environment values — {apiBaseUrl, gtmId, basePath}One place to see everything the app reads from the environment
auth.tsThe submit flow — useAuthSubmit, signOutThe one file to change when attaching a real backend
routes.tsRoute metadata — routePaths, authPaths, routePresentationThe shell reads this rather than the route definitions
image.tsx<Img/>A single place to switch image rendering
clientOnly.tsxDeferred renderingMarks 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).

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 keep src/platform/routes.ts in step so the shell knows the route exists.
  • Keep page components render-only; push state into a co-located useX hook when it grows.
  • Force per-route layout with presentation flags, not by mutating LayoutContext.
  • Register every new localStorage/sessionStorage key in appStorage.ts.

Troubleshooting

SymptomCause / 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 onlyStrictMode double-mount — set the ref true on mount, not just false on cleanup.
Reset didn't clear my new feature's stateThe key isn't in appStorage.ts. Add it.
Full-bleed page has a scroll gapSize 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 matching nav: 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.

Was this page helpful?