@natemac/app-router
v1.0.2
Published
An **abstract app-router** that works seamlessly across **Next.js 15 App Router** and **React Router v7**. This library exposes a **unified navigation interface**, **router events**, and **standardized access** to path/params/query so your app code does
Readme
🧭 App Router Abstraction
An abstract app-router that works seamlessly across Next.js 15 App Router and React Router v7.
This library exposes a unified navigation interface, router events, and standardized access to path/params/query so your app code doesn’t care which router it’s running on.
Why this exists: shared UI across web (Next.js) and SPA (React Router) often ends up with if/else routing code. This package removes those branches by providing a stable interface.
✨ Features
- 🚦 Unified API for both Next.js and React Router
- 🎯 Standardized router events:
onNavigateStart,onNavigateEnd - 🔑 History key tracking per location
- 🔄 App restart support (useful for kiosk/TV/embedded contexts)
- 🔍 Params & search params exposed in a consistent shape
- ⚡ Route prefetch passthrough (no-op when unsupported)
- 🧱 Small, framework-agnostic provider that you adapt with tiny wrappers
📦 Installation
npm install @natemac/app-router
# or
yarn add @natemac/app-router
pnpm add @natemac/app-router🧰 Core Concepts
This package centers around a single provider and a context:
RouterProvider– wraps your app and wires your platform/router specifics.RouterProviderContext– consume to usenavigate,back,prefetch, etc.- Events – get callbacks when navigations start/end along with
historyKeys.
The exposed interface (context value) is:
type RouterContextType = {
back: () => void;
navigate: (path: string) => void;
params: object;
prefetch: (route: string) => void;
restartApp: () => void;
searchParams: URLSearchParams;
};Provider props:
type RouterProviderProps = {
back: () => void;
children: React.ReactNode;
navigate: (path: string) => void;
onNavigateEnd?: (e: { historyKey: string; pathname: string }) => void;
onNavigateStart?: (e: {
historyKey: string; // current page key
nextHistoryKey: string; // about to navigate to this key
pathname: string; // current pathname
}) => void;
params: object;
pathname: string;
prefetch: (route: string) => void;
restartApp: () => void;
searchParams: URLSearchParams;
};Internals such as
useCombinedEffect,useHistoryKey,useLocationStoreare implementation details of this package and do not need to be imported in your app.
🚀 Quick Start
Wrap your app with RouterProvider and pass the functions/values from your router.
Next.js 15 (App Router)
Place this in a Client Component (add
'use client'at the top).
"use client";
import {
useRouter,
usePathname,
useSearchParams,
useParams,
} from "next/navigation";
import { RouterProvider } from "@natemac/app-router";
export default function NextRouterAdapter({
children,
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const pathname = usePathname() || "/";
const searchParams = useSearchParams();
const params = useParams();
return (
<RouterProvider
back={() => router.back()}
navigate={(path) => router.push(path)}
prefetch={(path) => router.prefetch?.(path)}
restartApp={() => window.location.reload()}
params={params}
pathname={pathname}
searchParams={searchParams}
onNavigateStart={({ pathname, nextHistoryKey }) => {
console.log("[start]", pathname, "→", nextHistoryKey);
}}
onNavigateEnd={({ pathname, historyKey }) => {
console.log("[end]", pathname, "→", , historyKey);
}}
>
{children}
</RouterProvider>
);
}React Router v7
import {
useLocation,
useNavigate,
useParams,
useSearchParams,
} from "react-router-dom";
import { RouterProvider } from "@natemac/app-router";
export function RR7RouterAdapter({ children }: { children: React.ReactNode }) {
const navigateRR = useNavigate();
const location = useLocation();
const params = useParams();
const [search] = useSearchParams();
return (
<RouterProvider
back={() => navigateRR(-1)}
navigate={(path) => navigateRR(path)}
prefetch={() => {
/* no-op in RR7 */
}}
restartApp={() => window.location.reload()}
params={params as object}
pathname={location.pathname}
searchParams={new URLSearchParams(search.toString())}
onNavigateStart={({ pathname, nextHistoryKey }) => {
console.log("[start]", pathname, "→", nextHistoryKey);
}}
onNavigateEnd={({ pathname, historyKey }) => {
console.log("[end]", pathname, "→", historyKey);
}}
>
{children}
</RouterProvider>
);
}🛠 Using the Context
Consume the context anywhere in your tree:
import * as React from "react";
import { useAppRouter, useSearchParams } from "@natemac/app-router";
export function NavButtons() {
const { navigate, back } = useAppRouter();
const searchParams = useSearchParams();
const id = searchParams.get("id") ?? "42";
return (
<div>
<button onClick={() => back()}>Back</button>
<button onClick={() => navigate(`/profile?id=${id}`)}>Profile</button>
</div>
);
}📡 Navigation Events
Two optional callbacks let you observe navigation:
onNavigateStart: fires before navigation occurs.onNavigateEnd: fires once the navigation is observed (history key/path updated).
Event payloads:
type NavigationEventProps = {
historyKey: string;
pathname: string;
};
// onNavigateStart gets { historyKey, nextHistoryKey, pathname }Note:
historyKeyis a best-effort unique identifier for the current entry. Different routers expose different primitives; this package normalizes them to a string key.
🧩 Patterns & Tips
- Prefetching: In Next.js,
router.prefetchis supported; in RR7 it’s a no-op. Your app code can still callprefetchsafely. - URLSearchParams: Standardized as
URLSearchParamsacross both routers. Next’sReadonlyURLSearchParamsis converted in the adapter. - SSR: Place adapters inside client boundaries when using Next.js hooks.
🧱 Minimal Interface Surface (what you implement)
If you create your own adapter, you only need to provide:
back(): voidnavigate(path: string): voidprefetch(route: string): void(can be a no-op)restartApp(): voidparams: objectpathname: stringsearchParams: URLSearchParams
The provider takes care of emitting events and tracking history keys.
📝 License
MIT © Nathan Macfarlane
