# Architecture

App Router-first Next.js 16 layout with feature-sliced modules, a single root providers tree, and clear server/client boundaries.

## App Router File Conventions

Every route segment is a folder. Special files:

| File | Purpose |
|------|---------|
| `page.tsx` | Route UI (server component by default) |
| `layout.tsx` | Persistent wrapper (auth layout, dashboard layout) |
| `loading.tsx` | Suspense fallback |
| `error.tsx` | Error boundary |
| `not-found.tsx` | 404 |
| `route.ts` | API endpoint (Better Auth catch-all lives here) |

## Route Groups

Two top-level groups enforce auth boundaries:

```
src/app/
├── (public)/
│   ├── login/page.tsx
│   ├── logout/page.tsx
│   └── error/page.tsx
└── (authed)/
    ├── layout.tsx              # AppShell + auth check
    └── dashboard/
        ├── page.tsx
        ├── users/
        │   ├── page.tsx
        │   └── [id]/page.tsx
        └── settings/page.tsx
```

Route groups (parenthesized folders) do not appear in URLs — `(authed)/dashboard/page.tsx` resolves to `/dashboard`.

## Providers Tree

A single client component composes all providers in `src/app/providers.tsx`. Order matters:

```tsx
// src/app/providers.tsx:1
"use client";

import { MantineProvider, createTheme } from "@mantine/core";
import { ModalsProvider } from "@mantine/modals";
import { Notifications } from "@mantine/notifications";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState, type ReactNode } from "react";

const theme = createTheme({
  primaryColor: "indigo",
  defaultRadius: "md",
});

export function Providers({ children }: { children: ReactNode }) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 30_000,
            retry: 1,
            refetchOnWindowFocus: false,
          },
        },
      }),
  );

  return (
    <MantineProvider theme={theme} defaultColorScheme="auto">
      <QueryClientProvider client={queryClient}>
        <ModalsProvider>
          <Notifications position="top-right" />
          {children}
        </ModalsProvider>
      </QueryClientProvider>
    </MantineProvider>
  );
}
```

Then mount in root layout:

```tsx
// src/app/layout.tsx:1
import "@mantine/core/styles.css";
import "@mantine/notifications/styles.css";
import "./globals.css";

import { ColorSchemeScript, mantineHtmlProps } from "@mantine/core";
import { Providers } from "./providers";

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en" {...mantineHtmlProps}>
      <head>
        <ColorSchemeScript defaultColorScheme="auto" />
      </head>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}
```

## Server vs Client Components

Use this decision matrix:

| Need | Component type |
|------|----------------|
| Read env or DB directly | Server |
| Fetch on the server with no user interaction | Server |
| Static markup, no interactivity | Server |
| `useState`, `useEffect`, event handlers | Client |
| Mantine components with state (most inputs) | Client |
| Browser-only APIs (`localStorage`, `window`) | Client |

Push client boundaries as low as possible. A page can be a server component that renders a small client island for an interactive widget.

Mark client components explicitly:

```tsx
"use client";

import { useState } from "react";
// ...
```

Never mark a component `"use client"` unless it actually needs it — it forces every import to be evaluated on the client.

## Feature-Sliced Layout

Each feature owns its full vertical slice. Cross-feature imports go through `src/components` only.

```
src/features/users/
├── api/
│   └── users.api.ts          # axios calls
├── components/
│   ├── UserTable.tsx
│   └── UserForm.tsx
├── hooks/
│   ├── useUsers.ts
│   └── useUpdateUser.ts
├── schemas/
│   └── user.schema.ts        # Zod
└── types.ts                  # re-exports inferred types
```

Rules:

- `features/users` may import from `src/components`, `src/lib`, `src/server` — never from `src/features/billing`.
- Public surface per feature is a single `index.ts` barrel — internal imports are private.
- Two features needing shared logic belong in `src/components` or a new shared `src/lib/<concern>` module.

## Shared vs Feature Code

| Lives in | When |
|----------|------|
| `src/components` | Used by 2+ features, no business logic |
| `src/lib` | Stateless utilities (axios, env loader, formatters) |
| `src/lib/auth` | Better Auth instances (server + client) |
| `src/server` | Server-only helpers (RSC data fetchers, permission checks) |
| `src/features/<x>` | Anything scoped to one feature |

## Env Loader

`src/lib/env.ts` validates at boot with Zod. Importing the module throws if env is malformed.

```ts
// src/lib/env.ts:1
import { z } from "zod";

const serverSchema = z.object({
  BETTER_AUTH_SECRET: z.string().min(32),
  BETTER_AUTH_URL: z.string().url(),
  DATABASE_URL: z.string().url(),
  KEYCLOAK_ISSUER: z.string().url(),
  KEYCLOAK_CLIENT_ID: z.string().min(1),
  KEYCLOAK_CLIENT_SECRET: z.string().min(1),
  LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
});

const publicSchema = z.object({
  NEXT_PUBLIC_API_BASE_URL: z.string().url(),
  NEXT_PUBLIC_APP_NAME: z.string().default("Dashboard"),
});

export const serverEnv = serverSchema.parse(process.env);
export const publicEnv = publicSchema.parse({
  NEXT_PUBLIC_API_BASE_URL: process.env.NEXT_PUBLIC_API_BASE_URL,
  NEXT_PUBLIC_APP_NAME: process.env.NEXT_PUBLIC_APP_NAME,
});
```

Server code imports from `src/lib/env` (this module transitively reads `process.env` and must never be imported from a `"use client"` file). Client code imports only `publicEnv`.

## Middleware

`src/middleware.ts` runs on every request matching `config.matcher`. It delegates auth checks to Better Auth.

```ts
// src/middleware.ts:1
import { NextResponse, type NextRequest } from "next/server";
import { getSession } from "@/lib/auth/server";

const PUBLIC_PATHS = new Set(["/login", "/error"]);

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  if (PUBLIC_PATHS.has(pathname) || pathname.startsWith("/api/auth")) {
    return NextResponse.next();
  }

  const session = await getSession(request);

  if (!session) {
    const loginUrl = new URL("/login", request.url);
    loginUrl.searchParams.set("redirectTo", pathname);
    return NextResponse.redirect(loginUrl);
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
```

## Layouts

`(authed)/layout.tsx` is the dashboard shell. It calls `getSession()` server-side to gate render and renders `AppShell`:

```tsx
// src/app/(authed)/layout.tsx:1
import { redirect } from "next/navigation";
import { AppShell } from "@/components/AppShell";
import { getSession } from "@/lib/auth/server";

export default async function AuthedLayout({ children }: { children: ReactNode }) {
  const session = await getSession();

  if (!session) redirect("/login");

  return <AppShell user={session.user}>{children}</AppShell>;
}
```

See [references/UI-AND-THEMING.md](UI-AND-THEMING.md) for the `AppShell` component.