Routing
File-based routing means your file structure inside src/app/ becomes your URL structure. Add a page.tsx and you have a route. Add a layout.tsx and it wraps every page beneath it. No router config, no manifest files.
File Conventions
Each directory in src/app/ can contain these special files:
| File | Purpose |
|---|---|
page.tsx | The UI for a route. Makes the directory publicly accessible |
layout.tsx | Shared UI that wraps child routes and persists across navigation |
loading.tsx | Loading UI shown while the page is streaming (wraps the page in a Suspense boundary) |
error.tsx | Error UI shown when a page or layout throws (wraps the page in an error boundary) |
not-found.tsx | Fallback UI shown when no route matches the requested path |
route.ts | API endpoint handler (cannot coexist with page.tsx in the same directory) |
opengraph-image.tsx | Dynamically generated Open Graph image for the route |
UI files also support .jsx extensions, and route.ts can be written as route.js.
Pages
A page.tsx file makes a route publicly accessible. Without it, a directory is just used for layout nesting or organization.
export default function HomePage() {
return <h1>Home</h1>
}export default function AboutPage() {
return <h1>About</h1>
}This gives you:
src/app/page.tsx→/src/app/about/page.tsx→/aboutsrc/app/blog/page.tsx→/blog
Pages are React Server Components by default. They can be async and fetch data directly:
export default async function PostsPage() {
const posts = await fetch('https://api.example.com/posts').then(r => r.json())
return (
<ul>
{posts.map((post: any) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}Layouts
A layout.tsx wraps all pages in its directory and below. Layouts persist across navigation and don't re-render when you navigate between sibling routes.
import type { LayoutProps } from 'rari'
export default function RootLayout({ children }: LayoutProps) {
return (
<html lang="en">
<body>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
</nav>
<main>{children}</main>
</body>
</html>
)
}Nested Layouts
Layouts in subdirectories wrap their own children and are themselves wrapped by the parent layout:
src/app/
├── layout.tsx # Root layout (wraps everything)
├── page.tsx # /
└── dashboard/
├── layout.tsx # Dashboard layout (wraps dashboard pages)
├── page.tsx # /dashboard
├── analytics/
│ └── page.tsx # /dashboard/analytics
└── settings/
└── page.tsx # /dashboard/settingsimport type { LayoutProps } from 'rari'
export default function DashboardLayout({ children }: LayoutProps) {
return (
<div style={{ display: 'flex' }}>
<aside>
<nav>
<a href="/dashboard">Overview</a>
<a href="/dashboard/analytics">Analytics</a>
<a href="/dashboard/settings">Settings</a>
</nav>
</aside>
<section>{children}</section>
</div>
)
}When you visit /dashboard/analytics, the rendering tree is:
<RootLayout> {/* src/app/layout.tsx */}
<DashboardLayout> {/* src/app/dashboard/layout.tsx */}
<AnalyticsPage /> {/* src/app/dashboard/analytics/page.tsx */}
</DashboardLayout>
</RootLayout>Navigating from /dashboard/analytics to /dashboard/settings only re-renders the page. Both layouts stay mounted.
Layout Props
Layouts receive children (required) and optionally params and pathname:
import type { LayoutProps } from 'rari'
export default function UserLayout({ children, params, pathname }: LayoutProps<{ id: string }>) {
return (
<div>
<p>Current path: {pathname}</p>
<p>User: {params?.id}</p>
{children}
</div>
)
}Dynamic Routes
Use square brackets to create routes that match dynamic segments.
Single Dynamic Segment
src/app/blog/[slug]/page.tsx → /blog/hello-world, /blog/my-post
src/app/users/[id]/page.tsx → /users/123, /users/abcimport type { PageProps } from 'rari'
export default async function BlogPost({ params }: PageProps<{ slug: string }>) {
const post = await fetch(`https://api.example.com/posts/`).then(r => r.json())
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
)
}Catch-All Segments
Use [...param] to match one or more segments:
src/app/docs/[...slug]/page.tsx
→ /docs/getting-started (slug = ["getting-started"])
→ /docs/api/reference/image (slug = ["api", "reference", "image"])import type { PageProps } from 'rari'
export default function DocsPage({ params }: PageProps<{ slug: string[] }>) {
const path = params.slug.join('/')
return <h1>Docs: {path}</h1>
}Catch-all segments require at least one segment. /docs alone would not match.
Optional Catch-All Segments
Use [[...param]] to also match the route without any segments:
src/app/docs/[[...slug]]/page.tsx
→ /docs (slug = undefined)
→ /docs/getting-started (slug = ["getting-started"])
→ /docs/api/reference/image (slug = ["api", "reference", "image"])import type { PageProps } from 'rari'
export default function DocsPage({ params }: PageProps<{ slug?: string[] }>) {
if (!params.slug) {
return <h1>Docs Home</h1>
}
return <h1>Docs: {params.slug.join('/')}</h1>
}Route Priority
When multiple routes could match a path, rari uses this priority order:
- Static routes (
/about), exact matches first - Dynamic routes (
/[id]), single-segment params - Catch-all routes (
/[...slug]), greedy matches - Optional catch-all routes (
/[[...slug]]), lowest priority
Loading States
A loading.tsx file creates a loading UI that's shown immediately while the page content streams in. Under the hood, rari wraps your page in a React Suspense boundary and uses loading.tsx as the fallback.
src/app/dashboard/
├── loading.tsx # Shown while page.tsx is loading
└── page.tsx # The actual page (async)export default function DashboardLoading() {
return (
<div>
<p>Loading dashboard...</p>
</div>
)
}export default async function DashboardPage() {
// This fetch might take a moment. loading.tsx is shown in the meantime
const stats = await fetch('https://api.example.com/stats').then(r => r.json())
return (
<div>
<h1>Dashboard</h1>
<p>Total users: {stats.totalUsers}</p>
</div>
)
}The loading state appears instantly on navigation, giving users immediate feedback while the server streams the actual content. Streaming and Suspense boundary handling are built into the Rust runtime.
Error Handling
An error.tsx file creates an error boundary that catches errors thrown by the page or its children. It receives the error and a reset function to retry rendering.
'use client'
import type { ErrorProps } from 'rari'
export default function DashboardError({ error, reset }: ErrorProps) {
return (
<div>
<h2>Something went wrong</h2>
<p>{error.message}</p>
<button onClick={reset} type="button">
Try again
</button>
</div>
)
}Error components must be client components ('use client') because they handle interactive retry logic in the browser.
Error Boundary Nesting
Error boundaries are scoped to their directory level. An error in /dashboard/analytics/page.tsx will be caught by the nearest error.tsx up the tree:
src/app/
├── error.tsx # Catches errors from root pages
├── dashboard/
│ ├── error.tsx # Catches errors from dashboard pages
│ ├── page.tsx
│ └── analytics/
│ └── page.tsx # Error here → caught by dashboard/error.tsxNot Found
A not-found.tsx file renders when no route matches the requested path. Place one at the root level to handle all unmatched routes:
export default function NotFound() {
return (
<div>
<h1>404</h1>
<p>This page could not be found.</p>
<a href="/">Go home</a>
</div>
)
}You can also place not-found.tsx in subdirectories for more specific 404 pages:
src/app/
├── not-found.tsx # Global 404
└── blog/
└── not-found.tsx # 404 for /blog/* routesNavigation
Use standard <a> tags for navigation. rari's client router intercepts link clicks and performs client-side navigation automatically. No special <Link> component needed:
export default function Navigation() {
return (
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
<a href="/blog">Blog</a>
</nav>
)
}Client-side navigation preserves layout state, avoids full-page reloads, and streams only the content that changed.
API Routes
Create API endpoints by exporting HTTP method handlers from a route.ts file:
src/app/api/
└── users/
├── route.ts # /api/users
└── [id]/
└── route.ts # /api/users/:idimport type { RouteHandler } from 'rari'
export const GET: RouteHandler = async (request) => {
const users = await fetchUsers()
return Response.json(users)
}
export const POST: RouteHandler = async (request) => {
const body = await request.json()
const user = await createUser(body)
return Response.json(user, { status: 201 })
}import type { RouteHandler } from 'rari'
export const GET: RouteHandler<{ id: string }> = async (request, context) => {
// context is always provided for dynamic routes
const { id } = context!.params
const user = await fetchUser(id)
if (!user) {
return Response.json({ error: 'Not found' }, { status: 404 })
}
return Response.json(user)
}
export const DELETE: RouteHandler<{ id: string }> = async (request, context) => {
const { id } = context!.params
await deleteUser(id)
return new Response(null, { status: 204 })
}Supported methods: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS. CORS is handled automatically in development.
Project Structure Example
A typical app showing all the file conventions together:
src/app/
├── layout.tsx # Root layout
├── page.tsx # /
├── loading.tsx # Global loading state
├── error.tsx # Global error boundary
├── not-found.tsx # Global 404
├── about/
│ └── page.tsx # /about
├── blog/
│ ├── layout.tsx # Blog layout
│ ├── page.tsx # /blog
│ └── [slug]/
│ ├── page.tsx # /blog/:slug
│ ├── loading.tsx # Loading state for blog posts
│ └── opengraph-image.tsx # Dynamic OG image
├── dashboard/
│ ├── layout.tsx # Dashboard layout (sidebar nav)
│ ├── page.tsx # /dashboard
│ ├── error.tsx # Error boundary for dashboard
│ ├── analytics/
│ │ └── page.tsx # /dashboard/analytics
│ └── settings/
│ └── page.tsx # /dashboard/settings
├── docs/
│ └── [[...slug]]/
│ └── page.tsx # /docs, /docs/*, /docs/*/*
└── api/
└── users/
├── route.ts # GET/POST /api/users
└── [id]/
└── route.ts # GET/PUT/DELETE /api/users/:idRelated
- Getting Started: Create your first rari app
- Metadata: Configure page titles, descriptions, and social cards
- Image Component: Optimize images
- ImageResponse: Generate dynamic Open Graph images