- Introduction
- Pitch
- Hogwarts
- Live Demo
- MVP
- Roadmap
- Launch Sprint
- PRD
- Get Started
- Localhost
- Architecture
- Structure
- Pattern
- Page
- Layout
- Content
- Types
- Config
- Actions
- Queries
- Authorization
- Validation
- Form
- Table
- Detail
- Card
- Util
- Hooks
- List Params
- Views
- README.md
- ISSUE.md
- Technology Stack
- Database
- File
- CDN Assets
- Entry Points
- Dashboard
- Authentication
- Credentials
- OAuth
- Flow Diagrams
- Multi-Tenancy
- Offline
- Onboarding
- Onboarding Videos
- Add Values
- Admission
- Application
- Attendance
- Compliance
- Profile
- Exams
- Exam Wizard
- Timetable
- Classrooms
- Notifications
- Conference
- LMS (Lumos)
- Finance
- Fee Management
- Invoice
- Wallet
- Salary
- Payroll
- Timesheet
- Expenses
- Budget
- Receipt
- Accounts
- Banking
- Reports
- Dashboard
- Permissions
- Messages
- Integration Flow
- Provision
- AI Document Processing
- Document Intelligence
- Internationalization
- Translation
- Translation Guide
- Icons
- Docs Factory
- Inspiration
- Listings
- Teachers
- Students
- Catalog
- Library
- Contributing
- Code of conduct
- GitHub Workflow
- Database Seeds
- Database Safety
- Test Accounts
- Playwright
- Prettier
- Block Rebound
Layouts span many route groups. Layouts are for shared UI and providers — never DB queries, never redundant auth, never inline translations. The deepest chain is 5 levels: root → [lang] → school-dashboard → feature → sub-feature.
The 7 rules
| # | Rule |
|---|---|
| 1 | export default [async] function Layout() — never arrow functions |
| 2 | import type for type-only imports |
| 3 | Layouts are for shared UI and providers only — no DB queries, no business logic |
| 4 | Never duplicate parent layout work (auth, school lookup, etc.) |
| 5 | Use the dictionary for all text — never inline isArabic ? ... : ... |
| 6 | Keep server layouts under ~60 lines; extract skeletons and error UI |
| 7 | Use Promise.all for parallel async ops |
Param shapes (3 total)
| Shape | Type | Used by |
|---|---|---|
| None | { children } | Root, passthrough, auth, onboarding, application |
| Lang only | { lang: string } | [lang], saas-dashboard, saas-marketing, docs, catalog |
| Lang + subdomain | { lang: string; subdomain: string } | school-dashboard, school-marketing, feature navs |
Async vs sync
| Layout type | Async? |
|---|---|
| Root | Sync |
| Locale | Async — params, auth, dictionary |
| Dashboard | Async — params, auth, school lookup |
| Feature nav | Async — params, dictionary |
| Client (onboarding, apply) | Sync — uses hooks |
| Passthrough | Sync |
Provider hierarchy
| Layout | Providers |
|---|---|
| Root | None — html/body only |
| Locale | DirectionProvider, SessionProvider, NuqsAdapter, ThemeProvider, UserThemeProvider, AnalyticsProvider, ServiceWorkerProvider, Toaster |
| School Dashboard | SchoolProvider, SidebarProvider, ModalProvider, PageHeadingProvider |
| SaaS Dashboard | SidebarProvider, ModalProvider, PageHeadingProvider |
| School Marketing | None (props pass school to header) |
| SaaS Marketing | None |
| Docs | SidebarProvider |
| Auth | None |
| Onboarding | ListingProvider, HostValidationProvider |
| Apply | ApplySessionProvider, ApplyValidationProvider |
Layouts cannot pass data to children directly — use providers, or let pages re-fetch (React cache deduplicates).
Template A — Root
Only layout with <html> and <body>. Inline script sets lang/dir before hydration.
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `(function(){var m=window.location.pathname.match(/^\\/(en|ar)/);var l=m?m[1]:'ar';document.documentElement.lang=l;document.documentElement.dir=l==='ar'?'rtl':'ltr'})()`,
}}
/>
</head>
<body>{children}</body>
</html>
)
}Template B — Locale
Provider hub. Async — needs params, auth, dictionary.
export default async function LocaleLayout({ children, params }: Props) {
const { lang } = await params
const session = await auth()
return (
<DirectionProvider direction={lang === "ar" ? "rtl" : "ltr"} lang={lang}>
<SessionProvider session={session}>
<NuqsAdapter>
<ThemeProvider>
<UserThemeProvider>{children}</UserThemeProvider>
</ThemeProvider>
</NuqsAdapter>
</SessionProvider>
</DirectionProvider>
)
}
export function generateStaticParams() {
return i18n.locales.map((lang) => ({ lang }))
}The real layout wraps 8 providers; the example shows the essential 5 for clarity.
Template C — Dashboard
force-dynamic. Auth and school fetched in parallel.
export const dynamic = "force-dynamic"
export default async function PlatformLayout({ children, params }: Props) {
const { subdomain, lang } = await params
const [result, session] = await Promise.all([
getSchoolBySubdomain(subdomain),
auth(),
])
if (!result.success || !result.data) notFound()
return (
<SchoolProvider school={result.data}>
<SidebarProvider>
<ModalProvider>
<PageHeadingProvider>{children}</PageHeadingProvider>
</ModalProvider>
</SidebarProvider>
</SchoolProvider>
)
}Child layouts must NOT re-call auth() or getSchoolBySubdomain().
Template D — Feature nav (~35 layouts)
The most common pattern. Dictionary + PageHeadingSetter + PageNav.
export default async function AttendanceLayout({ children, params }: Props) {
const { lang } = await params
const dictionary = await getDictionary(lang as Locale)
const d = dictionary?.school?.attendance
const pages: PageNavItem[] = [
{ name: d?.manual ?? "Mark", href: `/${lang}/attendance` },
{
name: d?.analytics ?? "Analytics",
href: `/${lang}/attendance/analytics`,
},
{ name: d?.reports ?? "Reports", href: `/${lang}/attendance/reports` },
{ name: d?.settings ?? "Settings", href: `/${lang}/attendance/settings` },
]
return (
<div className="space-y-6">
<PageHeadingSetter title={d?.title ?? "Attendance"} />
<PageNav pages={pages} />
{children}
</div>
)
}Template E — Passthrough
Sync. <>{children}</> for route grouping. Six layouts use this; consider whether the route group is even needed.
export default function ListingsLayout({
children,
}: {
children: React.ReactNode
}) {
return <>{children}</>
}Anti-patterns found
| Anti-pattern | Where | Fix |
|---|---|---|
| DB query in layout | catalog/(catalog)/layout.tsx runs db.catalogQuestion.count() | Move to the page that needs the count |
Redundant auth() | finance/banking/layout.tsx | Trust the parent dashboard layout |
Inline isArabic translations | school/layout.tsx, subjects/(browse)/layout.tsx | Use dictionary?.section?.key |
| 113-line client layout | onboarding/[id]/layout.tsx | Extract <LayoutSkeleton />, <LayoutError /> |
| 151-line client layout | apply/[id]/layout.tsx | Same |
| Arrow function export | (auth)/layout.tsx | export default function AuthLayout |
| Unused params | profile/layout.tsx declares but never uses | Drop the params |
Why not LayoutProps<'/route'>
Next.js 16 ships a global LayoutProps<'/route'> helper. Skipped because route literals are very long ('/[lang]/s/[subdomain]/(school-dashboard)/finance/banking'), only 3 param shapes exist, and the helper requires a next typegen step. Inline interface Props is more readable.
Behavioral facts
| Fact | Implication |
|---|---|
| Layouts don't re-render on soft navigation | State persists; only page.tsx and loading.tsx swap |
Layouts can't access searchParams | Only pages receive them |
Layouts can't access pathname | Use usePathname() in a client child |
error.tsx renders inside its layout | To catch a layout error, place error.tsx in the parent segment |
loading.tsx wraps children, not the layout | Suspense boundary inside the layout |
Root layout requires <html> and <body> | No other layout should have them |
Root layout must not have manual <head> tags | Use metadata / generateMetadata (inline scripts excepted) |
Next.js 16 reference
paramsis aPromisein layouts — synchronous access removed.- Layouts persist across soft navigation.
- Use
template.tsxif you need remount on navigation. - Turbopack is the default bundler.
- React Compiler is available (
reactCompiler: true) but not enabled.