Routes and Pages Guide
This guide explains how to create and configure routes and pages in the Open Saasframe framework, including authentication, authorization, and metadata configuration.
Table of Contents
- Overview
- Page Routes
- API Routes
- Authentication & Authorization
- Metadata Configuration
- Best Practices
- Examples
Overview
The Open Saasframe framework uses a module-based architecture where routes and pages are automatically discovered and registered. Routes are organized by module and can be either:
- Page Routes: Frontend pages that render UI components
- Backend Routes: Admin/backend pages with authentication
- API Routes: RESTful API endpoints for data operations
Page Routes
Frontend Pages
Frontend pages are located in src/modules/<module>/frontend/<path>.tsx and are automatically mapped to /<path>.
// packages/my-module/src/modules/my_module/frontend/products/page.tsx
export default function ProductsPage() {
return (
<div>
<h1>Products</h1>
<p>Browse our product catalog</p>
</div>
)
}
Backend Pages
Backend pages are located in src/modules/<module>/backend/<path>.tsx and are automatically mapped to /backend/<path>.
// packages/my-module/src/modules/my_module/backend/todos/page.tsx
import { Page, PageHeader, PageBody } from '@saasframe/ui/backend/Page'
export default function TodosPage() {
return (
<Page>
<PageHeader title="Todos" description="Manage your tasks" />
<PageBody>
<TodosTable />
</PageBody>
</Page>
)
}
Page Metadata
Pages can have metadata for navigation, authentication, and other configuration:
// packages/my-module/src/modules/my_module/backend/todos/page.meta.ts
export const metadata = {
requireAuth: true,
requireRoles: ['admin'] as const,
requireFeatures: ['example.todos.view'],
pageTitle: 'Todos',
pageGroup: 'Example',
pageOrder: 20,
icon: 'CheckSquare',
navHidden: false,
visible: true,
enabled: true
}
Metadata Properties:
requireAuth: Whether authentication is required (default: false)requireRoles: Array of roles required to access the pagepageTitle: Title displayed in navigation and page headerpageGroup: Group for organizing pages in navigationpageOrder: Order within the group (lower numbers appear first)icon: Icon name for navigation (optional)navHidden: Hide from navigation menu (default: false)visible: Whether the page is visible (default: true)enabled: Whether the page is enabled (default: true)
API Routes
API routes are located in src/modules/<module>/api/<path>/route.ts and are automatically mapped to /api/<path>.
Basic API Route
// packages/my-module/src/modules/my_module/api/todos/route.ts
import { NextResponse } from 'next/server'
import { createRequestContainer } from '@/lib/di/container'
import { getAuthFromCookies } from '@/lib/auth/server'
export async function GET(request: Request) {
try {
const container = await createRequestContainer()
const queryEngine = container.resolve<QueryEngine>('queryEngine')
// Your logic here
const todos = await queryEngine.find('todo', {
// query options
})
return NextResponse.json({ items: todos, total: todos.length })
} catch (error) {
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 })
}
}
Per-Method Metadata
API routes support per-HTTP-method metadata for fine-grained authorization:
// packages/my-module/src/modules/my_module/api/todos/route.ts
export const metadata = {
GET: {
requireAuth: true,
requireRoles: ['admin', 'user'],
requireFeatures: ['entities.records.view']
},
POST: {
requireAuth: true,
requireRoles: ['admin', 'superuser']
},
PUT: {
requireAuth: true,
requireRoles: ['admin'],
requireFeatures: ['entities.records.manage']
},
DELETE: {
requireAuth: true,
requireRoles: ['superuser']
}
}
export async function GET(request: Request) {
// Handler for GET requests
}
export async function POST(request: Request) {
// Handler for POST requests
}
export async function PUT(request: Request) {
// Handler for PUT requests
}
export async function DELETE(request: Request) {
// Handler for DELETE requests
}
Legacy Metadata Format
For backward compatibility, you can also use the legacy format:
// Legacy format (still supported)
export const requireAuth = true
export const requireRoles = ['admin']
Overriding Routes from Downstream Apps
Downstream apps can replace or disable API routes and page routes registered by an upstream module without forking the source. The override surface is the unified entry.overrides field on a ModuleEntry in apps/<app>/src/modules.ts; see Module overrides for the full domain list.
Two paths are wired.
Path A — modules.ts inline (per-app static decisions). Declare API overrides under overrides.routes.api, keyed by 'METHOD /api/path'. Declare page overrides under overrides.routes.pages, keyed by '/backend/path' or '/frontend/path'. A null value disables the matching route; an object replaces it.
// apps/<app>/src/modules.ts
import { NextResponse } from 'next/server'
export const enabledModules: ModuleEntry[] = [
{ id: 'example', from: '@app',
overrides: {
routes: {
api: {
// Disable an upstream module's `DELETE /api/example/items` endpoint.
'DELETE /api/example/items': null,
// Replace the handler for `POST /api/example/items` and tighten auth.
'POST /api/example/items': {
handler: async (req) => NextResponse.json({ ok: true }),
metadata: { requireAuth: true, requireFeatures: ['example.manage'] },
},
},
pages: {
// Disable an upstream backend page.
'/backend/example/reports': null,
// Replace page metadata while keeping the original component.
'/frontend/example': {
metadata: { title: 'Example storefront', navHidden: true },
},
},
},
},
},
]
The app's bootstrap.ts calls applyModuleOverridesFromEnabledModules(enabledModules) from @saasframe/shared/modules/overrides before the catch-all API route loads the manifest. Both apps/saasframe and the create-saasframe-app template ship that wiring out of the box.
Path B — programmatic API (boot-time / env-driven / test scaffolds). Use applyApiRouteOverrides or applyPageRouteOverrides from the same package. Programmatic overrides supersede modules.ts overrides for the same key.
import {
applyApiRouteOverrides,
applyPageRouteOverrides,
} from '@saasframe/shared/modules/overrides'
applyApiRouteOverrides({
'GET /api/example/items': null, // disable
})
applyPageRouteOverrides({
'/backend/example/reports': null, // disable
})
MUST rules:
- MUST key overrides as
'METHOD /api/path'. The method is normalized to uppercase; the path may omit the leading slash. Trailing slashes are stripped. - MUST key page overrides as
'/backend/path'or'/frontend/path'. Frontend route keys map to the public frontend route path; backend route keys map to/backend/.... - MUST keep replacement handlers same-signature as the original route (
(req, ctx?) => Promise<Response> | Response). - MUST call the dispatcher BEFORE route manifest registration runs — the override map is consulted once at manifest registration time. Apps that follow the standard
bootstrap.tsorder already do this. - MUST NOT use overrides to patch your own module's route — author it directly in
<module>/api/...,<module>/backend/..., or<module>/frontend/...instead. The convention is for cross-module replacement. - Disabling every method on a route drops the entry entirely (404 from the catch-all).
- Override keys that do not match any registered route log a single warning so an operator notices a stale override.
Resolution order (highest precedence first):
- Programmatic
applyApiRouteOverrides({...}). modules.tsinline (entry.overrides.routes.*; last entry per key wins).- The module's own route file (base).
null disables at every tier; a higher tier can resurrect a disabled route by mapping it back to a definition.
Authentication & Authorization
How It Works
- Page Routes: Authentication is checked when the page is accessed
- API Routes: Authentication is checked for each HTTP method based on metadata
- Automatic Redirects: Unauthenticated users are redirected to login
- Role-Based Access: Users must have required roles to access protected resources
Authentication Context
When authenticated, the following context is available:
interface AuthContext {
sub: string // User ID
tenantId: string // Tenant ID
orgId: string // Organization ID
email: string // User email
roles: string[] // User roles
}
Accessing Auth Context
In API routes:
export async function GET(request: Request) {
const container = await createRequestContainer()
const auth = await getAuthFromCookies()
if (!auth) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Use auth context
console.log('User ID:', auth.sub)
console.log('Organization:', auth.orgId)
console.log('Roles:', auth.roles)
}
In page components:
import { getAuthFromCookies } from '@/lib/auth/server'
export default async function ProtectedPage() {
const auth = await getAuthFromCookies()
if (!auth) {
redirect('/login')
}
return <div>Welcome, {auth.email}!</div>
}
Metadata Configuration
Page Metadata
Pages support comprehensive metadata for navigation and access control:
export const metadata = {
// Authentication
requireAuth: true,
requireRoles: ['admin'] as const,
// Navigation
pageTitle: 'User Management',
pageGroup: 'Administration',
pageOrder: 10,
icon: 'Users',
// Visibility
navHidden: false,
visible: true,
enabled: true
}
API Metadata
API routes support per-method metadata:
export const metadata = {
GET: {
requireAuth: true,
requireRoles: ['admin', 'user']
},
POST: {
requireAuth: true,
requireRoles: ['admin']
},
PUT: {
requireAuth: false // Public endpoint
},
DELETE: {
requireAuth: true,
requireRoles: ['superuser']
}
}
Best Practices
1. Use Appropriate Authentication Levels
// Public pages - no authentication
export const metadata = {
requireAuth: false
}
// Admin-only pages
export const metadata = {
requireAuth: true,
requireRoles: ['admin']
}
// Multi-role access
export const metadata = {
requireAuth: true,
requireRoles: ['admin', 'manager', 'user']
}
2. Organize Pages with Groups
// Administration pages
export const metadata = {
pageGroup: 'Administration',
pageOrder: 10
}
// User management pages
export const metadata = {
pageGroup: 'User Management',
pageOrder: 20
}
3. Use Per-Method API Authorization
export const metadata = {
GET: {
requireAuth: true,
requireRoles: ['admin', 'user'] // Read access for both
},
POST: {
requireAuth: true,
requireRoles: ['admin'] // Write access for admins only
},
DELETE: {
requireAuth: true,
requireRoles: ['superuser'] // Delete access for superusers only
}
}
4. Handle Errors Gracefully
export async function GET(request: Request) {
try {
// Your logic here
return NextResponse.json({ data: result })
} catch (error) {
console.error('API Error:', error)
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
)
}
}
5. Use TypeScript for Type Safety
interface TodoResponse {
items: Todo[]
total: number
page: number
pageSize: number
}
export async function GET(request: Request): Promise<Response> {
const result: TodoResponse = {
items: todos,
total: todos.length,
page: 1,
pageSize: 50
}
return NextResponse.json(result)
}
Examples
Complete Todo Management System
Backend Page:
// packages/my-module/src/modules/my_module/backend/todos/page.tsx
import { Page, PageHeader, PageBody } from '@saasframe/ui/backend/Page'
import TodosTable from '../../components/TodosTable'
export default function TodosPage() {
return (
<Page>
<PageHeader
title="Todos"
description="Manage your tasks with custom fields"
/>
<PageBody>
<TodosTable />
</PageBody>
</Page>
)
}
Page Metadata:
// packages/my-module/src/modules/my_module/backend/todos/page.meta.ts
export const metadata = {
requireAuth: true,
requireRoles: ['admin'] as const,
pageTitle: 'Todos',
pageGroup: 'Example',
pageOrder: 20,
icon: 'CheckSquare'
}
API Route:
// packages/my-module/src/modules/my_module/api/todos/route.ts
import { createRequestContainer } from '@/lib/di/container'
import { getAuthFromCookies } from '@/lib/auth/server'
import { E } from '@/generated/entities.ids.generated'
import type { QueryEngine } from '@saasframe/shared/lib/query/types'
export const metadata = {
GET: {
requireAuth: true,
requireRoles: ['admin']
},
POST: {
requireAuth: true,
requireRoles: ['admin', 'superuser']
}
}
export async function GET(request: Request) {
try {
const container = await createRequestContainer()
const queryEngine = container.resolve<QueryEngine>('queryEngine')
const url = new URL(request.url)
const page = parseInt(url.searchParams.get('page') || '1')
const pageSize = parseInt(url.searchParams.get('pageSize') || '50')
const todos = await queryEngine.find('todo', {
pagination: { page, pageSize },
filters: {
// Add filters based on query parameters
}
})
return NextResponse.json({
items: todos.items,
total: todos.total,
page,
pageSize,
totalPages: Math.ceil(todos.total / pageSize)
})
} catch (error) {
console.error('Error fetching todos:', error)
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
)
}
}
This comprehensive guide covers all aspects of creating routes and pages in the Open Saasframe framework. For more specific examples, see the API data fetching tutorial.