This document provides a comprehensive overview of Portal's architecture, module organization, and design patterns.
- Module Boundaries
- Server/Client Code Separation
- Data Fetching & RSC
- Integration Framework
- API Design Conventions
- Auth Guard Patterns
- Feature Module Conventions
- Database Conventions
src/
├── app/ # Next.js App Router
│ ├── (dashboard)/ # Protected dashboard routes
│ ├── api/ # API route handlers
│ └── auth/ # Authentication pages
├── components/ # Reusable React components
│ ├── ui/ # shadcn/ui base components
│ ├── layout/ # Layout components
│ ├── admin/ # Admin-specific components (if colocated)
│ └── integrations/ # Integration-specific components (if colocated)
├── features/ # Feature modules (auth, admin, integrations, routing)
│ ├── auth/lib/ # Auth DAL, config, permissions (@/auth)
│ ├── integrations/lib/ # Integration framework (registry, XMPP, etc.)
│ └── routing/lib/ # Route config, breadcrumbs
├── shared/ # Shared utilities and infrastructure
│ ├── api/ # Query client, server-queries, utils (@/shared/api)
│ ├── db/ # Database client and schema (@/db)
│ ├── observability/ # Logging, Sentry, wide events
│ └── utils/ # Constants, cn(), date helpers
└── hooks/ # Custom React hooks
Add to src/shared/ or src/features/ when:
- Creating reusable business logic (shared) or feature-specific logic (features)
- Adding new integrations or services (
src/features/integrations/lib/) - Implementing shared utilities (
src/shared/utils/) - Defining database schemas (
src/shared/db/schema/) - Creating API helpers (
src/shared/api/)
Add to src/components/ when:
- Creating reusable UI components
- Building feature-specific components
- Adding layout components
- Creating admin interfaces
Add to src/hooks/ when:
- Creating custom React hooks
- Wrapping TanStack Query queries
- Adding UI interaction hooks
- Creating data fetching hooks
Add to src/app/api/ when:
- Creating REST API endpoints
- Adding server-side handlers
- Implementing webhooks
- Creating public endpoints
Mark with:
"use server"directive for Server Actionsimport "server-only"for server-only modules
Examples:
- API route handlers (
src/app/api/) - Server Actions
- Database queries
- Auth utilities (
@/auth–src/features/auth/lib/, e.g. server-client) - Server-side configuration
Pattern:
import "server-only"
import { auth } from "@/auth"
import { db } from "@/db"
export async function getServerData() {
const session = await auth.api.getSession()
// Server-only code
}Mark with:
"use client"directive for Client Components
Examples:
- React components with interactivity
- Client-side hooks
- Browser APIs (localStorage, window)
- Client-side state management
Pattern:
"use client"
import { authClient } from "@/auth/client"
import { useState } from "react"
export function ClientComponent() {
const { data: session } = authClient.useSession()
// Client-side code
}-
Keep server code out of client bundles
- Never import server-only modules in client components
- Use API routes or Server Actions for server operations
-
Minimize client-side code
- Move business logic to server when possible
- Keep client components focused on UI
-
Clear boundaries
- Use TypeScript to catch server/client boundary violations
- Document server-only modules clearly
When to use what
- Server Components (default): Use for rendering that doesn’t need state, event handlers,
useEffect, or browser APIs. Prefer Server Components so more of the tree stays on the server and stays out of the client bundle. - Client Components (
"use client"): Use when you need interactivity,useState/useEffect, React Context, or browser APIs (e.g.localStorage,window).
Server-side data
- DAL +
React.cache(): Auth/session helpers (verifySession,getUser, etc.) in the Data Access Layer useReact.cache()for request-scoped memoization. DB and env access stay in server-only modules. - Server-queries: Prefetch/fetch in Server Components uses
getServerQueryClient()and server-only fetchers (e.g.src/shared/api/server-queries.ts). No DB access in client code.
Client-side data
- React Query: Client components that need mutations, refetch, or shared cache call
/api/*via TanStack Query hooks. Use the shared query key factory (src/shared/api/query-keys.ts) so prefetch and client hooks share the same keys.
Hybrid pattern (prefetch + hydrate)
- In a Server Component: create a per-request
QueryClient, callprefetchQuery(orfetchQuery) with the same query keys as the client hooks, then render<HydrationBoundary state={dehydrate(queryClient)}>wrapping the client subtree. - Client components use
useQuery/useSuspenseQuerywith those keys and receive the prefetched data without a loading round-trip. Keep the query key factory in sync between server prefetch and client hooks (e.g. admin users listlimitmust match between admin page prefetch andUserManagement).
Decision rule
- Prefer server fetch or prefetch for initial/SEO-critical data.
- Use React Query on the client when you need mutations,
invalidateQueries, or client-driven filters/pagination.
Caching layers
| Layer | Scope | Where |
|---|---|---|
React cache() |
Request (single render pass) | DAL verifySession, getUser, etc. |
"use cache" |
Persistent (cacheLife/cacheTag) | getStaticRouteMetadataCached in src/shared/seo/metadata.ts only |
| TanStack Query (server) | Per-request QueryClient |
Prefetch in Server Components, then dehydrate |
| TanStack Query (client) | Singleton QueryClient |
Hooks in Client Components, hydrated from server |
Portal uses a registry pattern for integrations. The framework lives under src/features/integrations/lib/.
- Registry:
src/features/integrations/lib/core/registry.ts– Central registry for all integrations - Factory:
src/features/integrations/lib/core/factory.ts– Utility for accessing integrations by id - Base/Types:
src/features/integrations/lib/core/– Base class, types, constants - Registration: Integrations register themselves via
getIntegrationRegistry().register()(e.g. in XMPP implementation)
-
Create integration module in
src/features/integrations/lib/[name]/src/features/integrations/lib/xmpp/ ├── index.ts # Public exports, registration ├── implementation.ts # Integration class extending base ├── keys.ts # Environment variables (t3-env) ├── config.ts # Configuration ├── types.ts # Integration-specific types └── client.ts # External service client (if needed) -
Implement the integration interface (see
src/features/integrations/lib/core/types.ts)import type { Integration } from "@/features/integrations/lib/core/types" export const xmppIntegration: Integration = { id: "xmpp", name: "XMPP", // Implement required methods }
-
Register in your implementation (e.g. call
getIntegrationRegistry().register(xmppIntegration)from the module that creates the instance), and ensureregisterIntegrations()is called where integrations are used (API routes, etc.). -
Add environment variables in the integration’s
keys.ts, and extendsrc/env.tswith that module’s keys.
- Registration: Integrations register on module load
- Discovery: Registry provides list of available integrations
- Instantiation: Factory creates integration instances
- Usage: Components and API routes use integrations
See docs/INTEGRATIONS.md for detailed integration documentation.
Success Response:
Response.json({ ok: true, data: {...} })Error Response:
Response.json({ ok: false, error: "Error message" }, { status: 400 })Use APIError class:
import { APIError } from "@/shared/api/utils"
throw new APIError("Resource not found", 404)Use handleAPIError() wrapper:
import { handleAPIError } from "@/shared/api/utils"
try {
// API logic
} catch (error) {
return handleAPIError(error)
}Error Handling Flow:
- Catch errors in route handlers
- Use
APIErrorfor known errors - Let
handleAPIError()handle unknown errors - Errors are automatically logged to Sentry
200 OK- Successful request201 Created- Resource created400 Bad Request- Invalid request401 Unauthorized- Authentication required403 Forbidden- Insufficient permissions404 Not Found- Resource not found500 Internal Server Error- Server error
Use Zod for validation:
import { z } from "zod"
const bodySchema = z.object({
name: z.string().min(1),
email: z.string().email(),
})
const body = bodySchema.parse(await request.json())Always use DTOs to prevent exposing sensitive data:
const userData = await db
.select({
id: user.id,
name: user.name,
email: user.email,
// Only select needed fields
})
.from(user)See docs/API.md for complete API documentation.
requireAuth() - Requires any authenticated user:
import { requireAuth } from "@/shared/api/utils"
const { userId, session } = await requireAuth(request)requireAdmin() - Requires admin role:
import { requireAdmin } from "@/shared/api/utils"
const { userId, session } = await requireAdmin(request)requireAdminOrStaff() - Requires admin or staff role:
import { requireAdminOrStaff } from "@/shared/api/utils"
const { userId, session } = await requireAdminOrStaff(request)export async function GET(request: NextRequest) {
try {
const { userId } = await requireAuth(request)
// Protected logic here
return Response.json({ ok: true, data })
} catch (error) {
return handleAPIError(error)
}
}Portal uses three roles:
user- Regular userstaff- Staff member (admin or staff guard)admin- Administrator (admin guard only)
Granular permissions are available via @/auth/permissions (src/features/auth/lib/permissions.ts):
import { checkPermission } from "@/auth/permissions"
const canManageUsers = await checkPermission(userId, "user:manage")Create a feature module when:
- Feature has multiple related components
- Feature has its own API routes
- Feature has complex business logic
- Feature needs isolation
1. Colocated Structure (for tightly coupled features):
src/components/admin/
├── user-management.tsx # Component
├── user-management.test.tsx # Tests
└── user-columns.tsx # Related utilities
When to colocate:
- ✅ Components and their tests
- ✅ Related utility functions
- ✅ Type definitions used only by the feature
- ✅ Small, focused features (< 5 files)
2. Separated Structure (for larger features). Portal uses this for admin and integrations:
src/
├── features/admin/
│ ├── components/ # user-management, data-table, etc.
│ ├── hooks/ # use-admin, use-admin-actions
│ └── api/ # API client helpers
├── app/api/admin/users/
│ └── route.ts
└── hooks/
└── use-permissions.ts # Shared across features
When to separate:
- ✅ Features spanning multiple concerns (UI, API, hooks)
- ✅ Shared utilities used across features
- ✅ Large features (> 5 files)
- ✅ Features with clear boundaries (admin, integrations)
- Files: Use kebab-case (
user-management.tsx) - Components: Use PascalCase (
UserManagement) - Functions/Variables: Use camelCase (
getUserData) - Constants: Use UPPER_CASE (
MAX_USERS) - Types/Interfaces: Use PascalCase (
UserData)
Prefer direct imports for performance:
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"Use barrel exports for core modules:
import { auth } from "@/auth"
import { db } from "@/db"Group imports:
// External dependencies
import { useState } from "react"
import { z } from "zod"
// Internal modules
import { auth } from "@/auth"
import { db } from "@/db"
// UI components
import { Button } from "@/components/ui/button"
// Relative imports
import { UserCard } from "./user-card"Use barrel exports for:
- Core modules (
@/auth,@/db,@/config) - Small utility modules
- Frequently imported modules
Use direct imports for:
- UI components (performance)
- Large modules (tree-shaking)
- One-off imports
Modular schemas in src/shared/db/schema/ (and @/db/schema/):
- One file per domain (
auth.ts,oauth.ts,api-keys.ts,integrations/base.ts) - Relations defined in
src/shared/db/relations.ts - Use Drizzle ORM for type-safe queries
Generate migrations:
pnpm db:generateRun migrations:
pnpm db:migrateBest practices:
- Review generated SQL before committing
- Never edit migration files manually after generation
- Test migrations on staging before production
- Always backup production database before migrations
Use DTOs to select only needed fields:
const users = await db
.select({
id: user.id,
name: user.name,
email: user.email,
})
.from(user)Avoid selecting entire tables:
// ❌ Bad
const users = await db.select().from(user)
// ✅ Good
const users = await db
.select({
id: user.id,
name: user.name,
})
.from(user)Use transactions for multi-step operations:
await db.transaction(async (tx) => {
await tx.insert(user).values({...})
await tx.insert(session).values({...})
})Handle errors gracefully:
try {
const result = await db.select().from(user)
} catch (error) {
// Handle database errors
log.error("Database query failed", error)
throw new APIError("Failed to fetch users", 500)
}-
Clear Module Boundaries
- Keep related code together
- Separate concerns (UI, API, business logic)
- Use consistent naming conventions
-
Server/Client Separation
- Mark server-only code explicitly
- Keep client bundles small
- Use API routes for server operations
-
Type Safety
- Use TypeScript strict mode
- Define types for all data structures
- Use Zod for runtime validation
-
Error Handling
- Use consistent error patterns
- Log errors to Sentry
- Return user-friendly error messages
-
Code Organization
- Follow established patterns
- Document complex logic
- Keep files focused and small
-
Performance
- Use direct imports for UI components
- Select only needed database fields
- Cache expensive operations
- docs/README.md — Index of all project docs
- API Documentation — REST API endpoints and route param validation
- Component Conventions — UI component guidelines
- Testing Guide — Testing patterns (Vitest, RTL)
- Integration Framework — Adding and implementing integrations
- PATH_ALIASES.md — TypeScript path aliases and targets