Skip to main content

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

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 page
  • pageTitle: Title displayed in navigation and page header
  • pageGroup: Group for organizing pages in navigation
  • pageOrder: 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.ts order 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):

  1. Programmatic applyApiRouteOverrides({...}).
  2. modules.ts inline (entry.overrides.routes.*; last entry per key wins).
  3. 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

  1. Page Routes: Authentication is checked when the page is accessed
  2. API Routes: Authentication is checked for each HTTP method based on metadata
  3. Automatic Redirects: Unauthenticated users are redirected to login
  4. 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.