FileRouter

The provides a powerful file-based routing system for single-page applications (SPA) and static site generation (SSG). It automatically generates routes based on your file structure, enabling you to create pages and layouts using conventional file naming patterns. With built-in SSG support, you can pre-render pages at build time for optimal performance and SEO.

Setting Up FileRouter

To get started with FileRouter, choose a mode and follow the setup below:

With CSR, use Vite's import.meta.glob feature to discover all pages and layouts in your application.
import { mount } from "kiru"
import { FileRouter } from "kiru/router"
import "./styles.css"

mount(
  <FileRouter
    config={{
      pages: import.meta.glob("/∗∗/index.{tsx,jsx}"),
      layouts: import.meta.glob("/∗∗/layout.{tsx,jsx}"),
    }}
  />,
  document.getElementById("app")
)

The config prop accepts:

  • dir: Root directory to resolve routes from (optional, defaults to /pages)
  • baseUrl: Base URL where your app is mounted (optional, defaults to /)
  • pages: Glob for page files
  • layouts: Glob for layout files
  • transition: Enable view transitions for route changes and page loaders (optional, defaults to false)

Kiru's FileRouter follows a convention-over-configuration approach, where your file structure directly maps to your application's routes.

FileRouter maps your file structure to URL routes. Here's how different file patterns map to routes:

Static Routes

  • pages/index.tsx/
  • pages/about/index.tsx/about
  • pages/docs/getting-started/index.tsx/docs/getting-started

Dynamic Routes

Use square brackets [] to create dynamic route segments:

  • pages/users/[id]/index.tsx/users/:id (e.g., /users/123)
  • pages/posts/[slug]/index.tsx/posts/:slug (e.g., /posts/my-post)
  • pages/[category]/[id]/index.tsx/:category/:id (e.g., /blog/123)

Layouts

Layout files are automatically detected and wrap their corresponding routes:

  • pages/layout.tsx → Root layout (wraps all routes)
  • pages/docs/layout.tsx → Layout for all /docs/* routes
  • pages/docs/api/layout.tsx → Layout for all /docs/api/* routes

Layouts are nested, so a route will be wrapped by all matching layouts from root to leaf.

FileRouter provides built-in support for loading data asynchronously using the definePageConfig function. This is useful for fetching data from APIs, databases, or any async source.

Basic Data Loading

Export a config object from your page component using definePageConfig:

import { definePageConfig, Link, PageProps } from "kiru/router"

interface FetchUsersResponse {
  users: {
    id: number
    firstName: string
    lastName: string
    image: string
  }[]
}

export const config = definePageConfig({
  loader: {
    load: async ({ signal }) => {
      const response = await fetch(
        "https://dummyjson.com/users?select=firstName,lastName,image",
        { signal }
      )
      if (!response.ok) throw new Error(response.statusText)
      return (await response.json()) as FetchUsersResponse
    },
  },
})

export default function Page({
  data,
  loading,
  error,
}: PageProps<typeof config>) {
  if (loading) return <p>Loading...</p>
  if (error) return <p>{String(error.cause)}</p>

  return (
    <div>
      <h1>Users</h1>
      <p>This is the users page</p>
      <div className="flex flex-col gap-2">
        {data.users.map((user) => (
          <div key={user.id} className="flex gap-2">
            <Link to={`/${user.id}`}>
              {user.firstName} {user.lastName}
            </Link>
            <img
              src={user.image}
              alt={user.firstName + " " + user.lastName}
              className="w-10 h-10 rounded-full"
            />
          </div>
        ))}
      </div>
    </div>
  )
}

The loader.load function receives a context object with:

  • signal: An AbortSignal for canceling requests
  • params: Route parameters (for dynamic routes)
  • query: URL query parameters
  • path: The current pathname

Caching for non-static loaders

Caching is available when the loader runs in client mode (default). Use the cache option to control where and how long results are stored. Cached values are re-used across navigations until the TTL expires or the route is invalidated.

import { definePageConfig } from "kiru/router"

export const config = definePageConfig({
  loader: {
    // default mode is "client"
    load: async ({ signal }) => {
      const res = await fetch("/api/articles", { signal })
      if (!res.ok) throw new Error(res.statusText)
      return await res.json()
    },
    cache: {
      type: "memory", // or "localStorage" | "sessionStorage"
      ttl: 1000 * 60 * 5, // 5 minutes
    },
  },
})

Cache Invalidation

You can invalidate a route by calling the invalidate method on the hook.

import { useFileRouter } from "kiru/router"

function MyComponent() {
  const router = useFileRouter()
  router.invalidate("/")
}

Caching is ignored for mode: "static" loaders since data is baked into the build output.

Accessing Loaded Data

Your page component receives the loaded data through :

  • data: The data returned from your loader (or null if not loaded)
  • loading: Boolean indicating if data is currently being loaded
  • error: Error object if loading failed (or null)

The loader function is called both on the client (for client-side navigation) and on the server (for SSG). Make sure your loader is safe to run both environments.

Dynamic Route Parameters

For dynamic routes, access route parameters in your loader:

import { definePageConfig, PageProps, useFileRouter } from "kiru/router"

interface FetchUserResponse {
  id: number
  firstName: string
  lastName: string
  image: string
  email: string
}

export const config = definePageConfig({
  loader: {
    load: async ({ signal, params }) => {
      const response = await fetch(
        `https://dummyjson.com/users/${params.id}?select=firstName,lastName,image,email`,
        { signal }
      )
      if (!response.ok) throw new Error(response.statusText)
      const user = (await response.json()) as FetchUserResponse
      return { user }
    },
  },
})

export default function UserDetailPage({
  data,
  loading,
  error,
}: PageProps<typeof config>) {
  const router = useFileRouter()

  if (loading) return <p>Loading...</p>
  if (error) return <p>{String(error.cause)}</p>

  return (
    <div>
      <button onclick={() => router.reload()}>Reload</button>
      <h1>User Detail</h1>
      <p>User ID: {data.user.id}</p>
      <p>
        User Name: {data.user.firstName} {data.user.lastName}
      </p>
      <img
        src={data.user.image}
        alt={data.user.firstName + " " + data.user.lastName}
        className="w-10 h-10 rounded-full"
      />
      <p>User Email: {data.user.email}</p>
      <button onclick={() => router.navigate("/users")}>Back to Users</button>
    </div>
  )
}

FileRouter includes built-in support for Static Site Generation (SSG), allowing you to pre-render pages at build time. SSG provides several benefits:

  • Performance: Pages are pre-rendered as static HTML, serving instantly
  • SEO: Search engines can easily crawl and index your content
  • Reduced server load: Static files can be served from CDNs
  • Better user experience: Faster initial page loads

How SSG Works

When you build your application with SSG enabled:

  1. Discovery: Kiru automatically discovers all routes from your file structure
  2. Pre-rendering: Each route is rendered to static HTML at build time
  3. Data Loading: Loaders with mode: "static" are executed during build to fetch data
  4. Output: Static HTML files are generated in the dist/client directory

Dynamic Routes with SSG

For dynamic routes (e.g., pages/posts/[id]/index.tsx), you need to provide a way to generate all possible route parameters at build time. This is typically done by:

  1. Fetching a list of all possible IDs from your data source
  2. Generating static pages for each ID
  3. Using the loader to fetch data for each specific ID

Example: generateStaticParams

import { definePageConfig } from "kiru/router"

export const config = definePageConfig({
  loader: {
    mode: "static", // bake data into the page at build time
    load: async ({ params }) => {
      const res = await fetch(`https://example.com/posts/${params.id}`)
      if (!res.ok) throw new Error(res.statusText)
      return await res.json()
    },
  },
  generateStaticParams: async () => {
    const res = await fetch("https://example.com/posts")
    const posts: Array<{ id: string }> = await res.json()
    return posts.map((p) => ({ id: p.id }))
  },
})

During SSG, your loader functions that have mode: "static" will be called for each route that needs to be pre-rendered. Make sure your data fetching logic can handle being called multiple times during the build process. When fetching data from a rate-limited API, use the ssg.build.maxConcurrentRenders plugin option to limit the number of concurrent requests during the build process.

Layouts allow you to wrap multiple routes with shared UI. They're automatically detected and applied based on your file structure.

Creating a Layout

Create a layout by adding a layout.tsx file in any directory:

import { Link, useFileRouter } from "kiru/router"

export default function RootLayout({ children }: { children: JSX.Children }) {
  const { state } = useFileRouter()

  return (
    <>
      <nav>
        <Link to="/" className={state.pathname === "/" ? "active" : ""}>
          Home
        </Link>
        <Link
          to="/users"
          className={state.pathname === "/users" ? "active" : ""}
        >
          Users
        </Link>
      </nav>
      <main>{children}</main>
    </>
  )
}

Nested Layouts

Layouts are nested automatically. For example:

  • pages/layout.tsx → Wraps all routes
  • pages/docs/layout.tsx → Wraps all /docs/* routes (nested inside root layout)
  • pages/docs/api/layout.tsx → Wraps all /docs/api/* routes (nested inside both)

This allows you to create a hierarchy of layouts, with each level adding its own UI elements while preserving the parent layouts.

Kiru's FileRouter mirrors Cloudflare Pages' 404 and SPA fallback behavior:

  • If a matching page isn't found, FileRouter will look for the nearest 404 page by walking up the directory tree (for example, pages/docs/404/index.tsx then pages/404/index.tsx).
  • If you don't provide a top-level pages/404/index.tsx in a CSR setup, deep links will render the root route and your app can handle a client-side 404 — similar to Cloudflare's SPA rendering mode, which maps unmatched URLs to the root when no top-level 404.html exists.

Reference: Cloudflare Pages – SPA rendering and 404 behavior.

Recommended setup

Provide explicit 404 pages where you need them:

// pages/404/index.tsx
export default function NotFound() {
  return (
    <div>
      <h1>Page not found</h1>
      <p>We couldn't find what you were looking for.</p>
    </div>
  )
}

Scoped 404 for a docs subtree:

// pages/docs/404/index.tsx
export default function DocsNotFound() {
  return (
    <div>
      <h1>Docs page not found</h1>
      <p>Check the sidebar or search the docs.</p>
    </div>
  )
}

In CSR deployments without a top-level 404 page, deep links will load the app shell and you should display a client-side 404 when no route matches. In SSG builds, ensure pages/404/index.tsx exists so a static 404 is emitted.

The hook provides access to the router instance and current route state:

import { useFileRouter } from "kiru/router"

function MyComponent() {
  const router = useFileRouter()
  const { pathname, params, query } = router.state
  
  return (
    <div>
      <p>Current path: {pathname}</p>
      <p>Route params: {JSON.stringify(params)}</p>
      <p>Query params: {JSON.stringify(query)}</p>
    </div>
  )
}

Router State

The router state includes:

  • pathname: Current pathname (e.g., "/users/123")
  • params: Route parameters (e.g., { id: "123" })
  • query: URL query parameters (e.g., { page: "1" })
  • hash: URL hash (e.g., "#section")

Router Methods

  • navigate(path): Navigate to a new route
  • reload(): Reload the current route
  • prefetchRouteModules(path): Manually prefetch a route's module/dependencies
  • invalidate(...paths: string[]): Invalidate one or more routes. This is useful for invalidating the cache for a specific route.