Application Structure & Route Groups
The src/app tree is organised into parenthesised route groups that choose a layout shell (feed, dashboard, reader, editor, profile, auth), not a URL prefix. This page explains how the groups nest, which layout owns which chrome, where auth gating happens, and the conventions that depend on the group structure. The nav chrome itself is covered in Navigation System, and logger categories in Logging & Observability.
Overview
Section titled “Overview”A directory wrapped in parentheses, such as (main), is left out of the URL but still takes part in layout nesting. Ozeaon uses groups for two things:
- Layout partitioning. Members of a group share a chrome shell.
(feed),(dashboard),(reader),(profile)and(editor)each render a different topbar, sidebar and shell combination, and most of them pass a differentpageTypetoSidebarProvider. - Sub-partitioning without new URLs. Nested groups such as
(private)/(public)inside(feed), and(personal)/(organizations)insidesettings, split a subtree by access tier or data scope while keeping the parent’s shell.
Because groups are invisible in the URL, similar URLs can sit under different layout chains. /projects lives in (main)/(feed)/(public)/projects, /projects/[slug] in (main)/(reader)/projects/[slug], and /projects/[slug]/edit in (main)/(editor). When a layout seems not to apply, check which directory the route file is in, not the URL.
Architecture
Section titled “Architecture”flowchart TD
Root["app/layout.tsx"] --> Auth["(auth)/layout.tsx"]
Root --> Main["(main)/layout.tsx<br/>NavSlotProvider, NavHistoryTracker,<br/>MobileFloatingCreate"]
Main --> Feed["(feed) pageType=feed"]
Main --> Dash["(dashboard) pageType=settings<br/>getAuthUserOrRedirect"]
Main --> Reader["(reader) pageType=single-entity<br/>@sidebar slot"]
Main --> Profile["(profile) pageType=single-entity"]
Main --> Editor["(editor) no SidebarProvider"]
Feed --> Private["(private) DynamicMarker"]
Feed --> Public["(public)"]
Dash --> Personal["settings/(personal)"]
Dash --> Orgs["settings/(organizations)"]
Profile --> OrgLayout["organizations/[slug]/layout.tsx<br/>EntityTitleSlot"]
Profile --> UserLayout["profiles/[username]/layout.tsx<br/>EntityTitleSlot"]
The full layout set is every layout.tsx under src/app. Besides the group layouts in the diagram, there are smaller nested layouts for (feed)/(private)/account, (feed)/(public)/projects (the section heading and metadata) and the two settings sub-groups.
Top Level: (auth) and (main)
Section titled “Top Level: (auth) and (main)”(auth)/layout.tsx holds login, signup, password reset and email verification. It renders the form next to AuthMarketingPanel, with no app chrome. It is a sibling of (main), so the app shell never wraps an auth screen.
(main) holds every non-auth route, public and signed-in. Auth gating happens in the (dashboard) layout and in individual pages, not in (main). (main)/layout.tsx mounts three things once for the whole product:
NavSlotProvider, the context that lets pages push content into the nav (entity titles, left/right slots, mega menu and mobile search state). Because it wraps every group,NavSlotContextworks in all of them.NavHistoryTracker, which records in-app navigation sosafeRouterBackknows whether one of our pages is behind the current one.MobileFloatingCreate, the mobile create button (see below).
Feature Groups Under (main)
Section titled “Feature Groups Under (main)”| Group | pageType |
Shell | Notes |
|---|---|---|---|
(feed) |
feed |
TwoColumnShell with AppTopbar and AppSidebar |
Home, posts, articles and projects lists, network, search, legal pages, account |
(dashboard) |
settings |
TwoColumnShell with DashboardSidebar, defaultOpen={false} |
Calls getAuthUserOrRedirect, then mounts DashboardNavSlot with the user’s admin orgs |
(reader) |
single-entity |
Hand-built topbar, sidebar, <main>, sticky right <aside> and Footer |
The only group with a parallel route (@sidebar) for the article/project right rail |
(profile) |
single-entity |
Topbar, sidebar, content and Footer |
Org and user profiles; each entity layout renders ProfilePageShell and tabs |
(editor) |
none | AppTopbar noUser neutralVariant and Footer |
Article, project and organisation create/edit forms; deliberately no SidebarProvider |
(feed), (reader) and (profile) read the sidebar open state on the server with getSidebarOpen, which reads the persisted state from a cookie. SidebarProvider then starts in the right state and the sidebar never flashes open and collapses. The feed layout is the canonical shape:
const sidebarOpen = await getSidebarOpen("feed");return ( <SidebarProvider pageType="feed" defaultOpen={sidebarOpen}> <TwoColumnShell topbar={<AppTopbar />} sidebar={<AppSidebar />}> {children} </TwoColumnShell> </SidebarProvider>);(dashboard) hardcodes defaultOpen={false}, because settings is visited on purpose and starts collapsed whatever the user’s feed preference is. (reader) and (profile) share pageType="single-entity" because the entity itself is the subject of the page, and the sidebar is secondary.
The shells live in src/components/ui/layout/shells/. TwoColumnShell takes topbar and sidebar as React nodes and centres the content column at the shared content width, so it doesn’t care what the chrome is. SidebarShell is the sticky left column used inside DashboardSidebar. Widths come from the shared layout constants, not from the layouts.
Auth Tiers
Section titled “Auth Tiers”Auth is enforced on the server in layouts and pages. The middleware does not redirect anonymous users (see Middleware & Sessions).
(dashboard)gates its whole subtree:DashboardShellcallsgetAuthUserOrRedirectinside aSuspense, so no settings page has to repeat the check.(feed)/(private)does not gate in its layout. Its layout only rendersDynamicMarker(anawait connection()), which forces the subtree to render dynamically. Each page under it (/account/posts,/account/organizations) callsgetAuthUserOrRedirectitself. An ESLint rule blocks the public Supabase client in files under(private).(editor)pages each callgetAuthUserOrRedirect.- Everything else under
(main)is public. It renders for visitors and personalises when a session exists.
Entity Titles in the Nav
Section titled “Entity Titles in the Nav”The topbar sometimes needs data owned by a page deep in the tree. Rather than pass props through layouts, a page or layout publishes its entity title into NavSlotContext with EntityTitleSlot, and SectionIndicator reads it and shows the entity name with a BackControl to the parent section. On unmount, the slot clears the title only if it still owns it.
EntityTitleSlot is mounted in two places, both in (profile): organizations/[slug]/layout.tsx (the org name) and profiles/[username]/layout.tsx (the display name). Putting it in the entity layout means every tab under /organizations/[slug]/* and /profiles/[username]/* gets the title. Reader pages use their own nav slots instead (for example ProjectNavSlot sets the left slot).
Mobile Floating Create
Section titled “Mobile Floating Create”MobileFloatingCreate is mounted once, from (main)/layout.tsx. It opens a Create menu on mobile, and sends visitors to login. It hides itself on form, settings and auth routes. The list lives in the component.
The hide logic lives in the component, not the layout, because (main)/layout.tsx cannot tell a project form from a project reader page by its own position in the tree. Both are its descendants, and the difference is in URL segments (new, edit) resolved much further down. Keeping the URL-shaped rules next to the component keeps the layout a plain mount point.
Route Groups Are Stripped, Not Renamed
Section titled “Route Groups Are Stripped, Not Renamed”docs/logging-conventions.md derives logger categories from the real path with the parenthesised segments removed, taking the first real segment after app. A file at app/(main)/(feed)/(public)/(home)/page.tsx logs as ["app", "feed"], not ["app", "home"]. Groups are layout names, not domain names, so wrappers such as (public), (private) and (home) must not become categories. Server Actions always use ["actions", X], and root-level files use ["app", "root"]. See Logging & Observability for the full taxonomy.
Failure Modes & Edge Cases
Section titled “Failure Modes & Edge Cases”- No
SidebarProviderin(editor). Chrome that reads sidebar context must go throughuseSidebarSafe(asTopNavdoes). A plainuseSidebarunder(editor), or under any new group without a provider, throws. - Different sidebar defaults per group. Moving a route from
(feed)to(dashboard)changes its starting sidebar state from the user’s saved preference to always collapsed. - A new page under
(private)is not gated automatically. The(private)layout only makes the subtree dynamic, so the page must callgetAuthUserOrRedirect(orgetAuthUserand handlenull) itself. - Renaming or re-parenting a group changes logger categories. Every logger beneath it is re-tagged.
- Stale hide patterns.
MobileFloatingCreatealso hides on/loginand/register, but those routes are outside(main)(and the signup route is/signup), so those entries never match a page that mounts it.
Extension Points
Section titled “Extension Points”- New area with existing chrome: add a directory under the right group. No new layout is needed.
- New area with different chrome: add a
(group)under(main)with its ownlayout.tsx, choose apageType, and choose a shell. - New access tier in a group: nest a parenthesised sub-group (as
(private)/(public)do) so the URLs stay the same. Remember the gate goes in the layout or page, not the group name. - New global overlay or provider: mount it in
(main)/layout.tsx. If visibility depends on the URL, put the rules in the component. - New entity page that should show its name in the nav: render
EntityTitleSlotin the entity’s layout.