Routing
Loly uses file-based routing. Routes are automatically created from your file structure.
File-Based Routing
Routes are automatically created from your file structure.Note: Static files in the public/ directory have priority over dynamic routes. See the Static Filesdocumentation for more details.
| File Path | Route |
|---|---|
app/page.tsx | / |
app/about/page.tsx | /about |
app/blog/[slug]/page.tsx | /blog/:slug |
app/post/[...path]/page.tsx | /post/* (catch-all) |
Dynamic Routes
Create dynamic routes using square brackets:
export default function BlogPost({ params }: { params: { slug: string } }) {
return <h1>Post: {params.slug}</h1>;
}Route parameters are passed directly as props to your component.
Catch-All Routes
Use three dots to create catch-all routes:
export default function Post({ params }: { params: { path: string[] } }) {
return <h1>Path: {params.path.join("/")}</h1>;
}Nested Layouts
Create nested layouts by adding a layout.tsx file:
export default function BlogLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div>
<aside>Sidebar</aside>
<main>{children}</main>
</div>
);
}Important: Layouts should NOT include <html> or <body> tags. The framework automatically handles the base HTML structure.
Route Groups
Route groups allow you to organize routes without affecting the URL structure. Use parentheses (name) to create a route group. The folder name in parentheses will be ignored when creating the route.
Key Points:
- Route groups do NOT affect the URL structure
- They are useful for organizing routes logically
- You can share layouts within a route group
- Multiple route groups can exist at the same level
Basic Example
Organize your routes by feature or section:
| File Path | Route |
|---|---|
app/(marketing)/about/page.tsx | /about |
app/(marketing)/contact/page.tsx | /contact |
app/(app)/dashboard/page.tsx | /dashboard |
app/(app)/settings/page.tsx | /settings |
Shared Layouts
You can create a layout that applies only to routes within a group:
export default function MarketingLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="marketing-theme">
<header>Marketing Header</header>
<main>{children}</main>
<footer>Marketing Footer</footer>
</div>
);
}This layout will apply to all routes inside (marketing), but the group name won't appear in the URL.
Multiple Groups
You can have multiple route groups at the same level:
app/
├── (marketing)/
│ ├── layout.tsx # Marketing layout
│ ├── about/
│ │ └── page.tsx # /about
│ └── contact/
│ └── page.tsx # /contact
├── (app)/
│ ├── layout.tsx # App layout
│ ├── dashboard/
│ │ └── page.tsx # /dashboard
│ └── settings/
│ └── page.tsx # /settings
└── page.tsx # /Each group can have its own layout, and routes are organized logically without affecting URLs.
Layout Server Hooks
Layouts can have their own server hooks that provide stable data shared across all pages. Create layout.server.hook.ts in the same directory as layout.tsx:
import type { ServerLoader } from "@lolyjs/core";
export const getServerSideProps: ServerLoader = async (ctx) => {
// Stable data shared across all pages
return {
props: {
appName: "My App",
navigation: [
{ href: "/", label: "Home" },
{ href: "/about", label: "About" },
{ href: "/blog", label: "Blog" },
],
},
metadata: {
// Base metadata for all pages
description: "My App - Description",
openGraph: {
siteName: "My App",
type: "website",
},
},
};
};Important: Server hooks execute once per render and when revalidated. They should only handle stable data that doesn't change frequently (like configuration, navigation menus, static content). For dynamic data that needs to be fetched on every request (like user sessions, locale, tenant context), use global.middleware.ts instead, which establishes context in ctx.locals.
Props Combination:
- Layout props (from
layout.server.hook.ts) are stable and available in both the layout and all pages - Page props (from
page.server.hook.ts) are specific to each page and override layout props if there's a conflict - Combined props are available in both layouts and pages
- Context data (from
global.middleware.tsviactx.locals) is available in all server hooks and can be passed as props
export default function RootLayout({ children, appName, navigation, user }) {
// appName and navigation come from layout.server.hook.ts
// user comes from ctx.locals.user (established by global.middleware.ts)
return (
<div>
<nav>
<h1>{appName}</h1>
{navigation.map(item => (
<Link key={item.href} href={item.href}>{item.label}</Link>
))}
</nav>
{children}
</div>
);
}Metadata Combination: Metadata is also combined intelligently:
- Layout metadata acts as base/fallback
- Page metadata overrides specific fields
- Nested objects (openGraph, twitter) are merged shallowly
Link Component
Use the Link component for client-side navigation:
import { Link } from "@lolyjs/core/components";
export default function Navigation() {
return (
<nav>
<Link href="/">Home</Link>
<Link href="/about">About</Link>
<Link href="/blog/[slug]" params={{ slug: "my-post" }}>
My Post
</Link>
</nav>
);
}Programmatic Navigation: Use navigate for programmatic navigation:
import { navigate } from "@lolyjs/core/runtime";
function handleClick() {
navigate("/dashboard");
}Static Route Generation
Generate static routes at build time using generateStaticParams:
import type { GenerateStaticParams, ServerLoader } from "@lolyjs/core";
export const generateStaticParams: GenerateStaticParams = async () => {
const posts = await getAllPosts();
return posts.map(post => ({
slug: post.slug,
}));
};
export const getServerSideProps: ServerLoader = async (ctx) => {
const post = await getPostBySlug(ctx.params.slug);
return {
props: { post },
};
};Special Routes
Create special routes for error handling:
Not Found (404)
export default function NotFound() {
return (
<div>
<h1>404</h1>
<p>Page not found</p>
</div>
);
}Error Page
export default function ErrorPage({ locals }) {
const error = locals.error;
return (
<div>
<h1>Error</h1>
<p>{error?.message || "Something went wrong"}</p>
</div>
);
}Route Middleware
Unique Loly feature: You can define middlewares directly in your routes using beforeServerData in page.server.hook.ts:
Note: For user session/authentication context that should be available everywhere, use global.middleware.ts instead. Route middlewares are best for route-specific validations, permissions, or transformations that are unique to that route.
import type { RouteMiddleware, ServerLoader } from "@lolyjs/core";
export const beforeServerData: RouteMiddleware[] = [
async (ctx, next) => {
// Route-specific authorization check
// Note: ctx.locals.user is already available from global.middleware.ts
const user = ctx.locals.user;
if (!user || user.role !== "admin") {
ctx.res.status(403).json({ error: "Forbidden" });
return;
}
await next();
},
];
export const getServerSideProps: ServerLoader = async (ctx) => {
// User is already in ctx.locals from global.middleware.ts
return {
props: {
user: ctx.locals.user,
},
};
};This separation allows you to keep server logic separate from React components, making testing and code organization easier.
URL Rewrites
URL rewrites allow you to rewrite routes internally without changing the visible URL in the browser. This is especially useful for multitenancy, API proxying, and other advanced routing scenarios.
Configuration
Create rewrites.config.ts in the root of your project:
import type { RewriteConfig } from "@lolyjs/core";
export default async function rewrites(): Promise<RewriteConfig> {
return [
// Static rewrite
{
source: "/old-path",
destination: "/new-path",
},
// Rewrite with parameters
{
source: "/tenant/:tenant/:path*",
destination: "/project/:tenant/:path*",
},
// Conditional rewrite by host (multitenant by subdomain)
{
source: "/:path*",
has: [
{ type: "host", value: ":tenant.localhost" },
],
destination: "/project/:tenant/:path*",
},
];
}Basic Rewrites
Simple static rewrites redirect one path to another:
export default async function rewrites(): Promise<RewriteConfig> {
return [
{
source: "/old-path",
destination: "/new-path",
},
{
source: "/legacy",
destination: "/modern",
},
];
}Rewrites with Parameters
Capture parameters from the source path and use them in the destination:
export default async function rewrites(): Promise<RewriteConfig> {
return [
{
source: "/tenant/:tenant/:path*",
destination: "/project/:tenant/:path*",
},
];
}This rewrite maps /tenant/acme/dashboard to /project/acme/dashboard internally, while keeping the original URL visible.
Multitenancy by Subdomain
The most common use case is multitenancy where each tenant has its own subdomain:
export default async function rewrites(): Promise<RewriteConfig> {
return [
// Catch-all: tenant1.localhost:3000/* → /project/tenant1/*
{
source: "/:path*",
has: [
{
type: "host",
value: ":tenant.localhost" // Captures tenant from subdomain
}
],
destination: "/project/:tenant/:path*",
},
];
}How it works:
- User visits:
tenant1.localhost:3000/dashboard - Internally rewrites to:
/project/tenant1/dashboard - Visible URL in browser:
tenant1.localhost:3000/dashboard(unchanged) - The route
/project/[tenantId]/dashboardreceivesparams.tenantId = "tenant1"
Accessing Extracted Parameters
Parameters extracted from rewrites (including host conditions) are available in:
ctx.params- Route parameters (if the rewritten route matches a dynamic route)ctx.req.query- Query parametersctx.req.locals- Request locals (for server hooks)
import type { ServerLoader } from "@lolyjs/core";
export const getServerSideProps: ServerLoader = async (ctx) => {
// tenantId comes from the rewrite: /project/:tenant/:path*
const tenantId = ctx.params.tenantId;
// Also available in req.query and req.locals
const tenantFromQuery = ctx.req.query.tenant;
const tenantFromLocals = ctx.req.locals?.tenant;
return {
props: { tenantId },
};
};Conditional Rewrites
Rewrites can be conditional based on request properties using the has array:
export default async function rewrites(): Promise<RewriteConfig> {
return [
// Rewrite based on host
{
source: "/:path*",
has: [
{ type: "host", value: "api.example.com" },
],
destination: "/api/:path*",
},
// Rewrite based on header
{
source: "/admin/:path*",
has: [
{ type: "header", key: "X-Admin-Key", value: "secret" },
],
destination: "/admin-panel/:path*",
},
// Rewrite based on cookie
{
source: "/premium/:path*",
has: [
{ type: "cookie", key: "premium", value: "true" },
],
destination: "/premium-content/:path*",
},
// Rewrite based on query parameter
{
source: "/:path*",
has: [
{ type: "query", key: "version", value: "v2" },
],
destination: "/v2/:path*",
},
];
}Pattern Syntax
| Pattern | Description | Example |
|---|---|---|
:param | Named parameter (matches one segment) | /user/:id |
:param* | Named catch-all (matches remaining path) | /docs/:path* |
* | Anonymous catch-all (matches remaining path) | /* |
Important Notes
- Rewrites are applied before route matching
- The original URL is preserved in the browser (not a redirect)
- Query parameters are preserved and can be extended
- Rewrites work for both pages and API routes
- If the rewritten route doesn't exist, a 404 is returned (strict behavior, no fallback)
- Catch-all patterns (
/:path*) are fully supported and recommended for multitenancy - WSS routes (
/wss/*) are automatically excluded (handled by Socket.IO) - System routes (
/static/*,/__fw/*,/favicon.ico) are automatically excluded - Rewrites are evaluated in order - the first match wins
- Functions in rewrite destinations cannot be serialized in production builds (only static rewrites are included in the manifest)