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:
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→/aboutpages/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/*routespages/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
AbortSignalfor 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
nullif 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:
- Discovery: Kiru automatically discovers all routes from your file structure
- Pre-rendering: Each route is rendered to static HTML at build time
- Data Loading: Loaders with
mode: "static"are executed during build to fetch data - Output: Static HTML files are generated in the
dist/clientdirectory
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:
- Fetching a list of all possible IDs from your data source
- Generating static pages for each ID
- 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 routespages/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
404page by walking up the directory tree (for example,pages/docs/404/index.tsxthenpages/404/index.tsx). - If you don't provide a top-level
pages/404/index.tsxin 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-level404.htmlexists.
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.