436
Documentation

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 PathRoute
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:

app/blog/[slug]/page.tsx
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:

app/post/[...path]/page.tsx
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:

app/blog/layout.tsx
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 PathRoute
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:

app/(marketing)/layout.tsx
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 structure
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:

app/layout.server.hook.ts
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.ts via ctx.locals) is available in all server hooks and can be passed as props
app/layout.tsx
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

Static Route Generation

Generate static routes at build time using generateStaticParams:

app/blog/[slug]/page.server.hook.ts
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)

app/_not-found.tsx
export default function NotFound() {
  return (
    <div>
      <h1>404</h1>
      <p>Page not found</p>
    </div>
  );
}

Error Page

app/_error.tsx
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.

app/admin/page.server.hook.ts
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:

rewrites.config.ts
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:

rewrites.config.ts
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:

rewrites.config.ts
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:

rewrites.config.ts
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]/dashboard receives params.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 parameters
  • ctx.req.locals - Request locals (for server hooks)
app/project/[tenantId]/dashboard/page.server.hook.ts
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:

rewrites.config.ts
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

PatternDescriptionExample
:paramNamed 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)