Architecture React
Everything framework-specific lives in resources/js. The Laravel side (modules, tenancy, auth) is described in Core architecture.
Overview
A request goes through Laravel first. The controller returns an Inertia response, and the React app renders the matching page inside a default layout.
Controller → Inertia::render('users/index', props)
→ @inertiajs/vite resolves pages/users/index.tsx
→ app.tsx picks a default layout from the page name
→ page renders with its props + shared propsresources/js/app.tsx creates the app:
- No
resolvecallback. The@inertiajs/viteplugin invite.config.tsfinds pages inresources/js/pages. layoutcallback. Picks the default layout from the page name (table below).withApp. Wraps every page inTooltipProviderand the sonnerToaster.strictMode: true, and the Vite React plugin runs the React Compiler.initializeTheme()(fromhooks/use-appearance.tsx) applies light, dark or system mode on load.
| Page name starts with | Default layout |
|---|---|
auth/ | AuthLayout |
settings/ | AppLayout wrapping settings/layout.tsx |
| anything else | AppLayout |
A page can override this with a static layout property. See Layouts.
View app.tsx
createInertiaApp({
title: (title) => (title ? `${title} - ${appName}` : appName),
layout: (name) => {
switch (true) {
case name.startsWith('auth/'):
return AuthLayout;
case name.startsWith('settings/'):
return [AppLayout, SettingsLayout];
default:
return AppLayout;
}
},
strictMode: true,
withApp(app) {
return (
<TooltipProvider delayDuration={0}>
{app}
<Toaster />
</TooltipProvider>
);
},
progress: { color: '#4B5563' },
});
initializeTheme();Folder map
| Folder / file | Purpose |
|---|---|
app.tsx | createInertiaApp, default layouts, providers, theme |
pages/ | Inertia pages: auth/, errors/, roles/, settings/, setup/, tenants/, users/, dashboard.tsx, home.tsx |
layouts/ | app-layout.tsx, auth-layout.tsx, their variants in app/ and auth/, and settings/layout.tsx |
components/common/ | The kit's form components (common-input.tsx, common-select.tsx, confirm-dialog.tsx, …) |
components/ui/ | shadcn/ui primitives (Radix UI) |
components/ | App shell and feature components (app-sidebar.tsx, app-header.tsx, nav-main.tsx, user-menu-content.tsx, …) |
hooks/ | use-x.ts(x) hooks (use-permission, use-confirm-dialog, use-language, …) |
lib/utils.ts | cn() and toUrl() |
types/ | Hand-written and generated TypeScript types |
lang/ | Generated translation JSON (php artisan erag:generate-lang) |
routes/, actions/, wayfinder/ | Generated by Wayfinder, ignored by git |
Naming
| Thing | Convention | Example |
|---|---|---|
| Page | kebab-case .tsx, default export | pages/tenants/index.tsx → TenantsIndex |
| Inertia page name | kebab-case path, no extension | Inertia::render('tenants/index') |
| Page-only component | partials/ next to the page | pages/users/partials/user-form-modal.tsx |
| Shared component | kebab-case file, PascalCase component | user-menu-content.tsx → UserMenuContent |
| Form component | common- prefix, named + default export | common-select.tsx → CommonSelect |
| Hook | use-x.ts exporting useX() | hooks/use-permission.ts |
| UI primitive | shadcn/ui file | components/ui/dialog.tsx |
ESLint enforces import type for type-only imports, alphabetised import groups, the React Hooks rules, 1TBS braces and blank lines around control statements.
Hooks
| File | Exports |
|---|---|
use-appearance.tsx | useAppearance(), initializeTheme(), updateTheme() |
use-clipboard.ts | useClipboard() |
use-confirm-dialog.ts | useConfirmDialog() → confirm(options), setLoading(); also exports confirm directly |
use-current-url.ts | useCurrentUrl() → isCurrentUrl, isCurrentOrParentUrl, whenCurrentUrl |
use-debounce.ts | useDebounce(value, delay), useDebounceFn(fn, delay) |
use-flash-toast.ts | useFlashToast() (used by the Toaster) |
use-initials.tsx | getInitials(), useInitials() |
use-language.ts | useLanguage(), DEFAULT_LANGUAGE_VALUE, toLocaleValue(), transformLocale() |
use-mobile.tsx, use-mobile-navigation.ts | useIsMobile(), useMobileNavigation() |
use-permission.ts | usePermission() → can(...permissions) |
use-secret.ts | generateSecret(), useSecret() |
use-slug.ts | slugify(), useSlug() |
use-status-badge.ts | useStatusBadge() → workspace status labels and badge classes |
use-subdomain.ts | extractSubdomain(), useSubdomain() |
use-two-factor-auth.ts | useTwoFactorAuth() (QR code, setup key, recovery codes via useHttp) |
users/use-assign-user-permissions.ts | State for the assign-permissions dialog |
Generated files
| Command | Output | Import as |
|---|---|---|
Wayfinder Vite plugin (npm run dev / build) | routes/, actions/ | @/routes/users, @/actions/Modules/<Module>/Http/Controllers/<Controller> |
php artisan typescript:transform | types/Modules/<Module>/Data and .../Enums | import type { DomainData } from '@/types/Modules/Tenant/Data' |
php artisan erag:generate-lang | lang/<locale>/modules/<feature>.json | through reactLang() |
Wayfinder runs with formVariants: true, so every route function also has .form(). Usage is covered in Inertia → Wayfinder routes.
Hand-written page prop types live next to the generated ones (types/Tenants/tenants.ts, types/Users/users.ts, types/Roles/roles.ts) and are re-exported from @/types. Pagination uses LengthAwarePaginator from @/types/Illuminate.
TIP
composer lint runs typescript:transform before the frontend linters, so generated types never drift from the PHP classes.
Shared props
HandleInertiaRequests shares these props on every page. They are typed as SharedData in types/global.d.ts.
| Prop | Type | Content |
|---|---|---|
name | string | App name (per-domain "App name" or tenant company on tenant domains) |
auth | Auth | user, isTenant, permissions, isSuperAdmin |
locale, userLocale, defaultLocale | string (userLocale may be null) | Active, user-selected and domain default locale |
languages | LanguageOption[] | Selectable languages |
menus, setupMenus | NavItem[] | Sidebar trees, already filtered by permission |
layout | LayoutSettings | app_layout, sidebar_variant, sidebar_collapsible, auth_layout |
sidebarOpen | boolean | Sidebar cookie state |
appUrl, domain | string, string | null | App URL and central domain |
permissionsConfig | PermissionGroup[] | Grouped permissions for the permission dialogs |
permissionsConfig is not declared on SharedData; it falls under the type's [key: string]: unknown index signature, so cast it where you read it (hooks/users/use-assign-user-permissions.ts casts it to PermissionGroup[]).
SharedData is registered with Inertia, so usePage().props is typed without a generic:
const { auth } = usePage().props;Page-specific props arrive as the component's own props, typed with a local Props type and destructured in the signature, for example Login({ status, canResetPassword }: Props) in pages/auth/login.tsx.
Permissions on the frontend
The backend already filters menus by permission. Inside a page, hide buttons and actions with usePermission():
const { can } = usePermission();
{can('Create User', 'Create Tenant User') && <CommonButton>…</CommonButton>}can()returnstrueif the user has any of the given permissions.- Super admins (
auth.isSuperAdmin) always gettrue. - Pass both the central and tenant permission names so the same page works on both domains.
WARNING
Hiding a button is only UX. The route must still check the permission with permission: middleware.
Translations
Every visible string goes through __() from reactLang(). Import it from the React subpath; the root import breaks the build.
import { reactLang } from '@erag/lang-sync-inertia/react';
const { __ } = reactLang();
<Head title={__('modules.user.index.title')} />- Keys map to
lang/<locale>/modules/<feature>.php. php artisan erag:generate-langexports them toresources/js/lang/<locale>/modules/<feature>.json.- Replacements:
__('modules.user.index.delete_confirm.message', { name: user.name }). reactLang()also returnstrans,transChoiceandtrans_choice.- To list or switch languages, use
useLanguage()(languages,changeLanguage).
More in Localization.
Related files
resources/js/app.tsx: app entry, default layouts, providersvite.config.ts: Inertia, React (with React Compiler), Tailwind and Wayfinder pluginsapp/Http/Middleware/HandleInertiaRequests.php: shared propsresources/js/types/global.d.ts:SharedData, globalPageProps<T>, Inertia type registrationresources/js/hooks/use-permission.ts,use-language.ts: permission and language helperseslint.config.js,tsconfig.json: lint and type-check rules