# How Instatic Admin Routing Differs from react-router-dom: A Custom Implementation Analysis

> Discover how Instatic's custom admin routing replaces react-router-dom, slashing 30KB bundle size while keeping API compatibility for your SPA.

- Repository: [CoreBunch/Instatic](https://github.com/CoreBunch/Instatic)
- Tags: deep-dive
- Published: 2026-07-03

---

**Instatic replaces react-router-dom with a ~150-line custom router located in `src/admin/lib/routing` that eliminates ~30 KB of gzipped bundle overhead while maintaining API compatibility for the admin SPA's flat route structure.**

The CoreBunch/Instatic repository implements a purpose-built routing solution for its administrative interface. Unlike standard React applications that import the full `react-router-dom` package, Instatic's admin routing uses a lightweight alternative optimized for a fixed set of four to ten static routes. This custom implementation prioritizes cold-load performance and eliminates unused features that would otherwise inflate the JavaScript bundle.

## Why Instatic Built a Custom Router Instead of Using react-router-dom

The decision to avoid `react-router-dom` stems from specific constraints of the admin UI's architecture and performance requirements.

### Bundle Size and Performance Impact

`react-router-dom` ships with a comprehensive feature set including loaders, actions, nested layouts, and data routers that Instatic never uses. Importing the full library would add approximately **30 KB gzipped** to every admin page load. The custom implementation in [`src/admin/lib/routing/Router.tsx`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/lib/routing/Router.tsx) compresses the entire routing logic into roughly **150 lines of code**, keeping the admin's eager bundle minimal and improving cold-start performance.

### Feature Set Mismatch

The admin router deliberately implements only the features required for the admin UI's flat structure. It supports static segments, `:param` placeholders, and a catch-all `*` wildcard, but explicitly disallows optional segments, nested routes, and regex-style matching. This constraint prevents accidental route-matching complexity and ensures the implementation remains tiny.

## Core Architectural Differences

The Instatic admin router differs from `react-router-dom` in its state management, navigation mechanics, and rendering behavior.

### Custom Navigation Events and State Management

Instead of relying on React's internal state for location changes, the admin router uses `history.pushState` and `history.replaceState` paired with a custom `instatic:locationchange` event. This event notifies all subscribed components that the location changed, allowing `useSyncExternalStore` to re-read the location without triggering a full React re-render loop. This approach differs from `react-router-dom`'s internal state management and reduces re-render overhead.

### Smooth Transitions with React.startTransition

Every navigation call in [`src/admin/lib/routing/Router.tsx`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/lib/routing/Router.tsx) is wrapped in `React.startTransition`. When a lazy-loaded admin workspace chunk is still loading, the UI continues displaying the previous page instead of flashing a `<Suspense>` fallback. This behavior provides smoother transitions than the default `react-router-dom` implementation, which may show loading states immediately upon navigation.

### Flat Route Table Structure

Unlike `react-router-dom`, which supports deeply nested route configurations, Instatic's admin router enforces a flat route table. The implementation matches the fixed set of admin paths without recursive route matching, simplifying the code and reducing runtime overhead.

## Implementation Details from the Source Code

The router implementation spans several key files under the `@admin/lib/routing` barrel export.

### Router.tsx Core Components

The file [`src/admin/lib/routing/Router.tsx`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/lib/routing/Router.tsx) exports the primary components: `Router`, `MemoryRouter`, `Routes`, `Route`, `Navigate`, and `Link`. These mirror the `react-router-dom` API surface but with streamlined implementations. The `MemoryRouter` variant enables testing without a real DOM history, useful for unit tests that need to control the initial route state.

### routerHooks.ts and Path Matching

The [`src/admin/lib/routing/routerHooks.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/lib/routing/routerHooks.ts) file implements the hook layer (`useLocation`, `useNavigate`, `useParams`, `useInRouterContext`) and the path-matching logic. The matching algorithm supports only three patterns: static strings, colon-prefixed parameters (`:param`), and the asterisk wildcard (`*`). This limited grammar keeps the matching function small and predictable compared to the complex pattern matching in `react-router-dom`.

### Encapsulation and Import Constraints

The router is strictly encapsulated behind the `@admin/lib/routing` barrel. Core application code and module code must never import these routing internals directly. This constraint, documented in [`docs/reference/admin-router.md`](https://github.com/CoreBunch/Instatic/blob/main/docs/reference/admin-router.md), ensures the routing logic remains confined to the admin UI and prevents accidental dependencies in other parts of the codebase.

## Usage Examples: Instatic vs. react-router-dom

### Mounting the Router

In a typical `react-router-dom` application, you would import `BrowserRouter` or `Router` from the package. In Instatic, the admin entry point in [`src/admin/main.tsx`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/main.tsx) uses the custom implementation:

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

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

```

### Defining Routes

The route definition syntax remains familiar but uses the admin-specific imports:

```tsx
import { Routes, Route, Navigate } from '@admin/lib/routing';
import AdminEntry from './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/media" element={<AdminEntry section="media" />} />
      <Route path="/admin/*" element={<Navigate to="/admin/dashboard" replace />} />
    </Routes>
  );
}

```

### Programmatic Navigation

While `react-router-dom` provides `useNavigate`, Instatic offers `useAdminNavigate` from [`src/admin/lib/useAdminNavigate.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/lib/useAdminNavigate.ts) for programmatic navigation with view-transition effects:

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

function SaveButton() {
  const navigate = useAdminNavigate();

  const handleSave = async () => {
    await saveData();
    navigate('/admin/content');
  };

  return <button onClick={handleSave}>Save</button>;
}

```

### Testing with MemoryRouter

Testing remains compatible with React Testing Library using the custom `MemoryRouter`:

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

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

expect(screen.getByText('Dashboard')).toBeInTheDocument();

```

## Comparison Summary

| Aspect | Instatic Admin Router | react-router-dom |
|--------|----------------------|------------------|
| **Bundle size** | ~150 lines, negligible gz | ~30 KB gz (full library) |
| **Feature set** | Flat routes, `:param`, `*`, no data loaders | Full feature set (loaders, actions, nested routes) |
| **Navigation mechanism** | `history.pushState` + custom `instatic:locationchange` event | Internal state management + `history` |
| **Transition handling** | `startTransition` wraps every navigation for smooth lazy-load | No built-in transition handling |
| **Route complexity** | Static and param only, no nesting | Supports nested, dynamic, and complex routes |
| **Import constraints** | Only `@admin/lib/routing` may import; forbidden in core | Imported freely throughout app |

## Summary

- **Instatic's admin routing** replaces `react-router-dom` with a ~150-line custom implementation to eliminate ~30 KB of bundle overhead.
- The router exposes a compatible API surface (`Router`, `Routes`, `Route`, `Link`, `useLocation`, `useNavigate`) but uses custom `instatic:locationchange` events and `history.pushState` for navigation.
- Every navigation is wrapped in `React.startTransition` to prevent Suspense fallbacks during lazy loading, providing smoother transitions than standard `react-router-dom`.
- The implementation supports only flat routes with static segments, `:param` placeholders, and `*` wildcards, deliberately excluding nested routes and complex matching patterns.
- All routing code is encapsulated in `src/admin/lib/routing` and must not be imported by core or module code outside the admin UI.

## Frequently Asked Questions

### Does Instatic's admin router support nested routes like react-router-dom?

No, the admin router intentionally does not support nested routes. According to the implementation in [`src/admin/lib/routing/routerHooks.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/lib/routing/routerHooks.ts), the router only handles flat route tables with static segments, `:param` placeholders, and `*` wildcards. This limitation keeps the implementation small and prevents the complexity of recursive route matching, which is unnecessary for the admin UI's fixed four-to-ten route structure.

### How does the custom router handle navigation without re-rendering the entire component tree?

The router uses `history.pushState` and `history.replaceState` combined with a custom `instatic:locationchange` event. Components subscribe to location changes using `useSyncExternalStore`, which reads the current location from the history state without requiring a React context update or full re-render. This approach differs from `react-router-dom`'s context-based updates and reduces render overhead during navigation.

### Can I use react-router-dom hooks like useSearchParams with Instatic's router?

No, the admin router does not implement `useSearchParams` or other data-router features from `react-router-dom`. The available hooks are limited to `useLocation`, `useNavigate`, `useParams`, and `useInRouterContext` as defined in [`src/admin/lib/routing/routerHooks.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/lib/routing/routerHooks.ts). If query parameter handling is required, you must parse `window.location.search` manually or use the `useLocation` hook to access the raw URL.

### Why does the admin router wrap navigation in React.startTransition?

The `React.startTransition` wrapper ensures that when navigating to a route with lazy-loaded components, React keeps the previous UI visible until the new chunk loads. This prevents the Suspense fallback from flashing between pages, creating a smoother user experience. According to the source in [`src/admin/lib/routing/Router.tsx`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/lib/routing/Router.tsx), this is applied automatically to every navigation call, unlike `react-router-dom` where you would need to manually implement such transitions.