Skip to content

olovalabs/router

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

12 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Olova Router

npm version downloads license

Next.js-style file-based routing for React + Vite applications — with nested layouts, dynamic routes, and zero configuration.


✨ Features

Feature Description
📁 File-based routing Create routes by adding folders with index.tsx
🏗️ Nested layouts Use _layout.tsx with <Outlet /> for persistent layouts
🎯 Dynamic routes Use $id or [id] syntax for dynamic segments
🌟 Catch-all routes Use $ or [...slug] for wildcard matching
📦 Route groups Use (group) folders to organize without affecting URLs
🔍 Search params Built-in useSearchParams hook with merge support
🚫 Custom 404 pages Global and route-specific 404 error pages
🔄 Hot reload Auto-regenerates routes when files change
📝 Type-safe Full TypeScript support with typed Link paths

📦 Installation

npm install olova-router

🚀 Quick Start

1. Add the Vite Plugin

// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { olovaRouter } from "olova-router";

export default defineConfig({
  plugins: [react(), olovaRouter()],
});

2. Create App Entry

// src/main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { routes, layouts, notFoundPages, OlovaRouter } from "./route.tree";

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <OlovaRouter
      routes={routes}
      layouts={layouts}
      notFoundPages={notFoundPages}
    />
  </StrictMode>
);

3. Create Your Routes

src/
├── _layout.tsx          → Root layout (persistent nav/header)
├── App.tsx              → / (home page)
├── 404.tsx              → Global 404 page
├── about/
│   └── index.tsx        → /about
├── users/
│   ├── index.tsx        → /users
│   └── $id/
│       └── index.tsx    → /users/:id (dynamic)
├── blog/
│   └── $/
│       └── index.tsx    → /blog/* (catch-all)
└── (auth)/              → Route group (not in URL)
    ├── login/
    │   └── index.tsx    → /login
    └── register/
        └── index.tsx    → /register

🏗️ Nested Layouts

Create persistent layouts that don't re-mount on navigation — headers, sidebars, and navs stay stable while only page content changes.

Create a Layout

// src/_layout.tsx
import { Link, Outlet } from "./route.tree";

export default function RootLayout() {
  return (
    <div>
      <nav>
        <Link href="/">Home</Link>
        <Link href="/about">About</Link>
        <Link href="/users">Users</Link>
      </nav>

      <main>
        <Outlet /> {/* Page content renders here */}
      </main>
    </div>
  );
}

How It Works

  • _layout.tsx in any folder creates a layout for that route scope
  • <Outlet /> renders the matched child route
  • Layouts stay mounted while navigating between child routes
  • No more flickering navbars!

🧭 Navigation

Using Link Component

import { Link } from "./route.tree";

function MyComponent() {
  return (
    <div>
      <Link href="/">Home</Link>
      <Link href="/about">About</Link>
      <Link href="/users/123">User 123</Link> {/* Dynamic paths work! */}
    </div>
  );
}

Programmatic Navigation

import { useRouter } from "./route.tree";

function MyComponent() {
  const { navigate, currentPath } = useRouter();

  return (
    <div>
      <p>Current path: {currentPath}</p>
      <button onClick={() => navigate("/users/456")}>Go to User 456</button>
    </div>
  );
}

🎯 Dynamic Routes

Use $param or [param] folder names for dynamic segments.

Folder Structure

src/users/$id/index.tsx     →  /users/:id
src/posts/[postId]/index.tsx  →  /posts/:postId

Access Params

// src/users/$id/index.tsx
import { useParams } from "./route.tree";

export default function UserPage() {
  const { id } = useParams<{ id: string }>();

  return <div>User ID: {id}</div>;
}

🌟 Catch-All Routes

Use $ or [...slug] for catch-all segments that match multiple path levels.

Folder Structure

src/blog/$/index.tsx           →  /blog/*
src/docs/[...slug]/index.tsx   →  /docs/*

Access Slug

// src/blog/$/index.tsx
import { useParams } from "./route.tree";

export default function BlogPost() {
  const { slug } = useParams<{ slug: string }>();

  // /blog/2024/hello-world → slug = "2024/hello-world"
  return <div>Blog path: {slug}</div>;
}

🔍 Search Params

Read and update URL query parameters.

import { useSearchParams } from "./route.tree";

function SearchPage() {
  const [searchParams, setSearchParams] = useSearchParams();

  // Read params - /search?q=react&page=2
  const query = searchParams.q; // "react"
  const page = searchParams.page; // "2"

  // Update params (merge with existing)
  setSearchParams({ page: "3" }, { merge: true });

  // Replace all params
  setSearchParams({ q: "vue", page: "1" });

  // Remove a param
  setSearchParams({ page: null }, { merge: true });

  // Use replaceState instead of pushState
  setSearchParams({ page: "5" }, { replace: true });
}

📦 Route Groups

Organize routes without affecting the URL using (parentheses).

Folder Structure

src/
├── (auth)/
│   ├── login/index.tsx      → /login
│   └── register/index.tsx   → /register
├── (marketing)/
│   ├── pricing/index.tsx    → /pricing
│   └── features/index.tsx   → /features

The group folder name (auth) is excluded from the URL.


🚫 Custom 404 Pages

Global 404

// src/404.tsx
import { Link, useRouter } from "./route.tree";

export default function NotFound() {
  const { currentPath } = useRouter();

  return (
    <div>
      <h1>404 - Page Not Found</h1>
      <p>Path "{currentPath}" doesn't exist.</p>
      <Link href="/">Go Home</Link>
    </div>
  );
}

Route-Specific 404

// src/dashboard/404.tsx
// Matches: /dashboard/anything-that-doesnt-exist

export default function DashboardNotFound() {
  return <div>Dashboard page not found</div>;
}

The most specific 404 page is used based on path prefix.


📋 Route Pattern Reference

File Path URL Pattern
src/App.tsx /
src/about/index.tsx /about
src/users/$id/index.tsx /users/:id
src/users/[id]/index.tsx /users/:id
src/blog/$/index.tsx /blog/*
src/blog/[...slug]/index.tsx /blog/*
src/(auth)/login/index.tsx /login
src/users/_layout.tsx Layout for /users/*
src/404.tsx Global 404
src/users/404.tsx 404 for /users/*

Route Metadata

Add SEO metadata to any route by exporting a metadata object:

// src/about/index.tsx
export const metadata = {
  title: "About Us - My App",
  description: "Learn more about our company and mission",
  keywords: ["about", "company", "mission"],
};

export default function About() {
  return <h1>About Us</h1>;
}

The router automatically updates the document title and meta tags when navigating.

Property Type Description
title string Page title
description string Meta description
keywords string[] Meta keywords
[key] any Custom metadata fields

Client-Only Data Loading

Use clientOnly to define loaders and actions for routes:

// src/users/$id/index.tsx
import { useLoaderData, useParams, clientOnly } from "@/route.tree";

// Define the route configuration
export const route = clientOnly({
  loader: async ({ params }) => {
    const res = await fetch(`/api/users/${params.id}`);
    return res.json();
  },
  pendingComponent: () => <div>Loading user...</div>,
  errorComponent: ({ error, retry }) => (
    <div>
      <p>Error: {error.message}</p>
      <button onClick={retry}>Retry</button>
    </div>
  ),
});

export default function UserProfile() {
  const user = useLoaderData();
  const { id } = useParams();

  return <h1>{user.name}</h1>;
}

clientOnly Options

Option Type Description
loader (ctx) => Promise<T> Fetch data before rendering
action (ctx) => Promise<T> Handle form submissions
pendingComponent ComponentType Show while loading
errorComponent ComponentType<{error, retry}> Show on error with retry
preload boolean Preload data on link hover
staleTime number Cache duration in ms
validateParams (params) => T Validate/transform route params
beforeEnter (ctx) => boolean Guard navigation

Data Hooks

import {
  useLoaderData, // Access loader data
  useActionData, // Access action result
  usePending, // Check if loading/submitting
  useIsLoading, // Check if loader is running
  useIsSubmitting, // Check if action is running
  useLoaderError, // Get loader error
  useSubmit, // Programmatic form submission
} from "@/route.tree";

⚙️ API Reference

Plugin Options

olovaRouter({
  rootDir: "src", // Directory to scan (default: 'src')
  extensions: [".tsx", ".ts"], // File extensions (default: ['.tsx', '.ts'])
});

Hooks

Hook Returns Description
useRouter() { currentPath, params, navigate, searchParams, setSearchParams } Full router access
useParams<T>() T Route parameters object
useSearchParams() [params, setParams] Query string params

Components

Component Props Description
OlovaRouter routes, layouts?, notFoundPages?, notFound? Main router
Link href, children, className? Type-safe navigation
Outlet Renders nested route content

Types

import type {
  RoutePaths, // Union of all route paths
  SearchParams, // Search params object type
  SetSearchParamsOptions,
  LayoutRoute,
  NotFoundPageConfig,
} from "./route.tree";

🔄 Auto-Generated Files

The plugin automatically generates src/route.tree.ts containing:

  • All route imports and configurations
  • Layout configurations
  • 404 page configurations
  • Type-safe Link component
  • All hooks re-exported

Do not edit this file manually — it's regenerated when routes change.


💡 Tips

Path Aliases

Add a path alias for cleaner imports:

// tsconfig.json
{
  "compilerOptions": {
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}
// vite.config.ts
import path from "path";

export default defineConfig({
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./src"),
    },
  },
});

Then import from @/route.tree anywhere.


📄 License

MIT © 2026


Made with ❤️ for the React community