Use standard HTML <a> tags for navigation. The client router automatically intercepts link clicks and performs client-side navigation, giving you the benefits of a single-page application without needing a special <Link> component or API overhead.

Basic Usage

Just use regular anchor tags:

src/app/page.tsx
export default function HomePage() {
  return (
    <nav>
      <a href="/">Home</a>
      <a href="/about">About</a>
      <a href="/blog">Blog</a>
    </nav>
  )
}

That's it. No imports, no special components, no learning curve. If you know HTML, you know how to navigate in rari.

How It Works

When you click a link, rari's client router:

  1. Intercepts the click event on <a> tags
  2. Prevents the default browser navigation
  3. Fetches only the new page content (not the entire HTML document)
  4. Updates the URL in the browser
  5. Streams the new content into the page
  6. Preserves layout state and scroll position

This gives you instant navigation with no full-page reloads, while keeping your code simple and standards-based.

Client-Side Navigation Benefits

Layout Preservation

Layouts don't re-render during navigation. If you have a sidebar, header, or any shared UI in a layout component, it stays mounted and preserves its state:

src/app/dashboard/layout.tsx
import { useState } from 'react'
import type { LayoutProps } from 'rari'

export default function DashboardLayout({ children }: LayoutProps) {
  const [sidebarOpen, setSidebarOpen] = useState(true)

  return (
    <div>
      <aside>
        {/* This sidebar state persists across page navigation */}
        <button onClick={() => setSidebarOpen(!sidebarOpen)}>
          Toggle
        </button>
        <nav>
          <a href="/dashboard">Overview</a>
          <a href="/dashboard/analytics">Analytics</a>
          <a href="/dashboard/settings">Settings</a>
        </nav>
      </aside>
      <main>{children}</main>
    </div>
  )
}

Navigating between /dashboard/analytics and /dashboard/settings only swaps the page content. The sidebar stays mounted with sidebarOpen intact.

Streaming Updates

Only the changed content is fetched and streamed. If you navigate from /blog to /blog/hello-world, rari fetches just the blog post page, not the entire document with layouts and navigation.

Optimized Performance

The router includes built-in optimizations:

  • Debounced navigation to prevent rapid-fire requests
  • Automatic request cancellation when navigating away
  • Route info caching to avoid redundant fetches
  • Scroll position restoration on back/forward navigation

Since links are just <a> tags, style them however you want. CSS, Tailwind, CSS modules, styled-components, anything:

src/app/components/Navigation.tsx
export default function Navigation() {
  return (
    <nav className="flex gap-4">
      <a
        href="/products"
        className="px-4 py-2 text-gray-700 hover:text-gray-900 hover:bg-gray-100 rounded-md transition-colors"
      >
        Products
      </a>
      <a
        href="/pricing"
        className="px-4 py-2 text-white bg-indigo-600 hover:bg-indigo-700 rounded-md transition-colors"
      >
        Pricing
      </a>
    </nav>
  )
}

Use the pathname prop available in layouts and pages to style active links:

src/app/layout.tsx
import type { LayoutProps } from 'rari'

export default function RootLayout({ children, pathname }: LayoutProps) {
  const isActive = (path: string) => pathname === path

  return (
    <div>
      <nav>
        <a
          href="/"
          className={isActive('/') ? 'text-indigo-600 font-semibold' : 'text-gray-700'}
        >
          Home
        </a>
        <a
          href="/about"
          className={isActive('/about') ? 'text-indigo-600 font-semibold' : 'text-gray-700'}
        >
          About
        </a>
      </nav>
      <main>{children}</main>
    </div>
  )
}

For more complex active state logic, you can check if the pathname starts with a certain path:

const isActiveSection = (path: string) => pathname?.startsWith(path)

<a
  href="/docs/getting-started"
  className={isActiveSection('/docs') ? 'active' : ''}
>
  Documentation
</a>

External links work exactly as you'd expect. They're not intercepted and perform normal browser navigation:

export default function Footer() {
  return (
    <footer>
      <a href="https://github.com/your-repo" target="_blank" rel="noopener noreferrer">
        GitHub
      </a>
      <a href="https://twitter.com/your-handle" target="_blank" rel="noopener noreferrer">
        Twitter
      </a>
    </footer>
  )
}

The router automatically detects external URLs and lets the browser handle them normally.

Hash links scroll to elements on the current page:

<a href="#features">Jump to Features</a>

<section id="features">
  <h2>Features</h2>
</section>

The router intercepts hash links and performs smooth scrolling to the target element.

Links with the download attribute are not intercepted:

<a href="/files/report.pdf" download>
  Download Report
</a>

Modified Clicks

Links clicked with modifier keys (Ctrl, Cmd, Shift, Alt) are not intercepted, allowing users to open links in new tabs or windows as expected:

  • Cmd/Ctrl + Click: Opens in new tab
  • Shift + Click: Opens in new window
  • Alt + Click: Downloads the link

Target Attribute

Links with target attributes other than _self are not intercepted:

<a href="/admin" target="_blank">
  Open Admin in New Tab
</a>

Programmatic Navigation

Sometimes you need to navigate in response to events, form submissions, or conditional logic, not just link clicks. rari provides two ways to do this.

The navigate Function

For navigation from non-component code (e.g., event handlers, utilities, or server/standalone scripts), import navigate directly. Inside React components, prefer useRouter for in-component navigation:

src/app/components/SearchForm.tsx
'use client'

import { navigate } from 'rari/router'

export default function SearchForm() {
  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault()
    const formData = new FormData(e.currentTarget)
    const query = formData.get('q') as string
    await navigate(`/search?q=`)
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="q" placeholder="Search..." />
      <button type="submit">Search</button>
    </form>
  )
}

navigate accepts an optional second argument with navigation options:

// Replace the current history entry instead of pushing
await navigate('/dashboard', { replace: true })

The navigate function is client-side only. If called before the router initializes (during early hydration), it falls back to window.location automatically. When using the fallback, the replace option is respected by calling window.location.replace(...) instead of assigning window.location.href.

The useRouter Hook

For React components that need full router access, useRouter gives you navigation methods plus reactive route state:

src/app/components/LogoutButton.tsx
'use client'

import { useRouter } from 'rari/router'

export default function LogoutButton() {
  const router = useRouter()

  async function handleLogout() {
    await fetch('/api/logout', { method: 'POST' })
    await router.push('/login')
  }

  return <button onClick={handleLogout}>Log out</button>
}

The hook returns:

Method / PropertyDescription
push(href, options?)Navigate to a new URL (adds history entry)
replace(href, options?)Navigate without adding a history entry
back()Go back (equivalent to browser back button)
forward()Go forward
refresh()Re-render the current route
prefetch(href)Prefetch a route for faster future navigation
pathnameCurrent pathname (reactive)
paramsCurrent route parameters (reactive)
searchParamsCurrent URL search params (reactive)

Both navigate and router.push/router.replace accept the same options:

OptionTypeDefaultDescription
replacebooleanfalseReplace the current history entry instead of pushing

Convenience Hooks

If you only need a specific piece of route state, use the focused hooks to avoid unnecessary re-renders:

'use client'

import { usePathname, useParams, useSearchParams } from 'rari/router'

function Breadcrumb() {
  const pathname = usePathname()
  // ...
}

function ProductPage() {
  const params = useParams() // { slug: 'my-product' }
  // ...
}

function FilteredList() {
  const searchParams = useSearchParams() // URLSearchParams
  const sort = searchParams.get('sort')
  // ...
}

When to Use What

  • <a> tags: the default. Use for all standard navigation.
  • navigate(): for navigation outside React components, or when you don't need reactive route state.
  • useRouter(): for components that need to navigate and read route state.

Prefetching

You can prefetch routes programmatically using the useRouter hook:

'use client'

import { useRouter } from 'rari/router'

export default function ProductCard({ href }: { href: string }) {
  const router = useRouter()

  return (
    <div onMouseEnter={() => router.prefetch(href)}>
      <a href={href}>View Product</a>
    </div>
  )
}

Automatic link prefetching is not built in. The streaming architecture and route caching keep navigation fast enough that it hasn't been necessary. This may change in a future release based on feedback.

The decision to use standard <a> tags instead of a custom component is intentional:

  1. Zero learning curve: If you know HTML, you already know how to navigate
  2. Better DX: No imports, no prop mapping, no framework-specific APIs
  3. Standards-based: Works with any styling solution, accessibility tools, and browser features
  4. Less code: Fewer abstractions mean less to maintain and debug
  5. Progressive enhancement: Links work even if JavaScript fails to load

The client router handles all the complexity behind the scenes, giving you SPA-like navigation with the simplicity of static HTML.

  • Routing: Learn about file-based routing and route conventions
  • Layouts: Understand layout persistence during navigation