# How the Instatic Admin Routing System Works Without react-router-dom

> Discover how Instatic's custom admin routing system works without react-router-dom. Learn about its History API, custom events, and React contexts for seamless SPA navigation.

- Repository: [CoreBunch/Instatic](https://github.com/CoreBunch/Instatic)
- Tags: internals
- Published: 2026-07-27

---

**The Instatic admin routing system replaces react-router-dom with a lightweight, custom SPA router located in `src/admin/lib/routing/` that uses the History API, a custom `instatic:locationchange` event, and React contexts to handle navigation across the admin dashboard.**

The CoreBunch/Instatic project implements a bespoke routing solution for its administrative interface that deliberately avoids external dependencies like react-router-dom. This custom Instatic admin routing system resides entirely within `src/admin/lib/routing/` and provides a minimal, type-safe API for managing the `/admin/*` single-page application. By leveraging browser history APIs directly and synchronizing state through custom events, the router maintains a flat, predictable route structure while keeping bundle size minimal.

## Core Components and Architecture

### Router and MemoryRouter

The foundation of the system lives in [`src/admin/lib/routing/Router.tsx`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/lib/routing/Router.tsx). The **Router** component registers a global `popstate` listener and intercepts calls to `history.pushState` and `history.replaceState`. When navigation occurs, it emits a custom `instatic:locationchange` event, ensuring all subscribers synchronize without triggering unnecessary re-renders. For testing environments, **MemoryRouter** provides an identical API but maintains an in-memory history stack instead of modifying the browser’s URL.

### Routes and Route Matching

The **Routes** component iterates over its direct **Route** children in declaration order, selecting the first pattern that matches the current pathname. Each `Route` is declarative metadata only—it never renders itself. The matching logic relies on a `matchPath` function implemented in [`src/admin/lib/routing/routerHooks.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/lib/routing/routerHooks.ts) that compiles patterns like `/admin/:id` or `*` wildcards into regular expressions. Because the system uses flat matching without nested route trees, route order determines precedence, requiring catch-all patterns like `/admin/*` to appear last.

### Navigation Components

Two components handle user navigation imperatively and declaratively:

- **Navigate**: Renders nothing but pushes or replaces a location immediately upon mounting, useful for redirects.
- **Link**: Renders a standard `<a>` element that intercepts left-clicks, invokes the router’s navigation function, and prevents full page reloads. It respects modifier keys, non-left clicks, and `target="_blank"` attributes by falling back to native navigation behavior.

### Router Hooks

The [`src/admin/lib/routing/routerHooks.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/lib/routing/routerHooks.ts) file exports essential hooks that consume contexts provided by `Router` or `MemoryRouter`:

- `useLocation`: Returns the current location object.
- `useNavigate`: Returns an imperative function to push or replace history entries.
- `useParams`: Extracts URL parameters from matched routes.
- `useInRouterContext`: Detects whether the component renders within a router context.

These hooks leverage `useSyncExternalStore` internally to subscribe to the `instatic:locationchange` event, ensuring UI updates always reflect the browser’s history state.

## How Navigation Works

The Instatic admin routing system follows a synchronous, event-driven flow:

1. **Initialization**: [`src/admin/main.tsx`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/main.tsx) mounts the `Router` component around the `<AdminRoutes/>` application shell, installing listeners for `popstate` and `instatic:locationchange` events.
2. **Trigger**: Calling `navigate(to, {replace})` or clicking a `Link` invokes `history.pushState` or `replaceState` wrapped in `React.startTransition`.
3. **Broadcast**: The router dispatches the custom `instatic:locationchange` event.
4. **Synchronization**: Hooks subscribed via `useSyncExternalStore` detect the event, re-read `window.location`, and trigger React renders for components consuming `useLocation` or `useParams`.
5. **Rendering**: The `Routes` component re-evaluates its children, calls `matchPath` against the new location, and renders the first matching `element`.

## Implementation Examples

### Setting Up the Router

The entry point wraps the admin application in the custom Router:

```tsx
// src/admin/main.tsx
import { Router } from '@admin/lib/routing';
import { AdminRoutes } from './router';

export default function AdminApp() {
  return (
    <Router>
      <AdminRoutes />
    </Router>
  );
}

```

### Defining Admin Routes

The route table uses declarative components similar to react-router but with stricter ordering requirements:

```tsx
// src/admin/router.tsx
import { Routes, Route, Navigate } from '@admin/lib/routing';
import { AdminEntry } from './pages/AdminEntry';

export function AdminRoutes() {
  return (
    <Routes>
      <Route path="/" element={<Navigate to="/admin/dashboard" replace />} />
      <Route path="/admin/dashboard" element={<AdminEntry section="dashboard" />} />
      <Route path="/admin/site" element={<AdminEntry section="site" />} />
      {/* Workspace routes */}
      <Route path="/admin/*" element={<Navigate to="/admin/dashboard" replace />} />
    </Routes>
  );
}

```

### Navigation and Parameters

Access routing functionality through the custom hooks:

```tsx
import { useNavigate, useParams } from '@admin/lib/routing';

function ToolbarButton() {
  const navigate = useNavigate();
  return (
    <button onClick={() => navigate('/admin/media')}>
      Open Media
    </button>
  );
}

function PluginPage() {
  const { pluginId, pageId } = useParams<{ pluginId: string; pageId: string }>();
  return (
    <div>Plugin: {pluginId} – Page: {pageId}</div>
  );
}

```

Use the `Link` component for declarative navigation:

```tsx
import { Link } from '@admin/lib/routing';

function NavItem() {
  return <Link to="/admin/content">Content</Link>;
}

```

### Testing with MemoryRouter

For unit tests, replace `Router` with `MemoryRouter` to avoid browser history side effects:

```tsx
import { MemoryRouter, Routes, Route } from '@admin/lib/routing';
import { render } from '@testing-library/react';

render(
  <MemoryRouter initialEntries={['/admin/dashboard']}>
    <Routes>
      <Route path="/admin/dashboard" element={<Dashboard />} />
    </Routes>
  </MemoryRouter>
);

```

## Summary

- The Instatic admin routing system lives entirely in `src/admin/lib/routing/` and replaces react-router-dom with a zero-dependency implementation.
- **Router.tsx** contains the core components (`Router`, `MemoryRouter`, `Routes`, `Route`, `Link`, `Navigate`) that manage history and rendering.
- **routerHooks.ts** provides `useLocation`, `useNavigate`, `useParams`, and `useInRouterContext` backed by `useSyncExternalStore`.
- Navigation relies on the History API and a custom `instatic:locationchange` event to synchronize all subscribers efficiently.
- Pattern matching supports `:param` segments and `*` wildcards via regex compilation in `matchPath`, with route order determining precedence.
- The system is fully type-safe and includes `MemoryRouter` for isolated testing environments.

## Frequently Asked Questions

### Why does Instatic avoid react-router-dom?

The CoreBunch/Instatic team built a custom router to minimize bundle size and eliminate unnecessary features for the admin SPA. By keeping the implementation in `src/admin/lib/routing/`, they maintain full control over the routing API while ensuring the history API remains the single source of truth.

### How does the router handle URL parameters?

The `matchPath` function in [`src/admin/lib/routing/routerHooks.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/lib/routing/routerHooks.ts) compiles route patterns containing `:param` segments into regular expressions. When a match occurs, it extracts parameters into an object accessible via the `useParams()` hook, which returns type-safe parameter values based on the route definition.

### Can I use nested routes with this system?

No, the Instatic admin routing system deliberately uses a flat route structure. The `Routes` component only examines its direct children, and there is no support for nested layout routes or outlet rendering. Route precedence depends entirely on declaration order, so specific routes must precede catch-all patterns like `/admin/*`.

### What happens if I use router hooks outside of a Router component?

Hooks like `useLocation`, `useNavigate`, and `useParams` detect whether they are rendered within a router context using `useInRouterContext`. If called outside the `Router` or `MemoryRouter` tree, they throw an error to prevent undefined behavior, similar to react-router’s error handling.