Building a Custom React Router
File-based route discovery, pattern matching, and ViewTransition API with React 19.
Part 2 of the "Building a Modern Portfolio Without a Meta-Framework" series
Routing is one of those things that seems simple until you actually build it.
In Part 1, I mentioned that using useRouter for years without understanding what happens underneath left gaps in my knowledge. Building a router from scratch filled those gaps—and taught me that most of what React Router does, I don't need.
Why Not Just Use React Router?
React Router is battle-tested and handles edge cases I'll never encounter. So why reinvent the wheel?
- Bundle size: React Router v7 is ~15KB minified. My router is under 2KB. For a portfolio with five routes, that delta matters.
- ViewTransition API: I wanted first-class support for the new ViewTransition API, not a plugin or workaround.
- Lazy loading control: I wanted to see exactly how code splitting works with routes, not trust a framework to handle it.
The real answer? I wanted to understand how routing actually works. The bundle savings are a bonus.
File-Based Route Discovery
Before diving into the router itself, here's how routes are defined. Instead of manually maintaining an array of route objects, I use Vite's import.meta.glob to auto-discover pages from the filesystem, this is heavily inspired by SvelteKit's routing conventions:
// src/routes.ts
import type { Route } from './lib/router';
import { lazyWithPreload } from './lib/router';
import { PageOptions } from './lib/router/types';
// Eagerly load +Page.options.ts
const optionImports = import.meta.glob<PageOptions>(
'./pages/**/+Page.options.{ts,tsx}',
{ eager: true },
);
// Lazily load +Page.tsx components with preload capability
const pageImports = import.meta.glob<{ default: React.ComponentType<any> }>(
'./pages/**/+Page.tsx',
{ eager: false },
);
export const routes: Route[] = Object.entries(pageImports)
.map(([path, module]) => {
let optionsPath = path.replace('+Page.tsx', '+Page.options.ts');
let options = optionImports[optionsPath] || {};
const routePath = path.replace('./pages', '').replace('+Page.tsx', '');
return {
...options,
path: routePath === '/404/' ? '*' : routePath,
component: lazyWithPreload(module),
};
})
.sort((a, b) => {
// Ensure catch-all route is last
if (a.path === '*') return 1;
if (b.path === '*') return -1;
return 0;
});
The file structure determines the routes:
Route Options
Each route can have an accompanying +Page.options.ts file that exports metadata, loaders, and entry points:
// src/pages/+Page.options.ts
import { RouteMeta } from '@/lib/router';
export const meta: RouteMeta = {
title: 'Adam Grady | Senior Software Engineer',
description: 'Experienced Software Engineer...',
sitemapPriority: 1.0,
};
For dynamic routes, meta can be a function that computes values from params:
// src/pages/blog/[slug]/+Page.options.ts
import {
getBlogPostMeta,
blogPosts,
loadBlogPostContent,
} from '@/content/blog';
export const meta = (params) => getBlogPostMeta(params.slug);
export const load = async ({ params }) => {
return await loadBlogPostContent(params.slug);
};
export const entries = () => blogPosts.map((post) => `/blog/${post.slug}`);
A few things to notice:
- Options are eagerly loaded: They're small and needed immediately for routing decisions.
- Components are lazy: The actual page code only loads when the route matches.
- Dynamic routes use
[param]folders: Similar to Next.js/SvelteKit convention. entries()for dynamic routes: Returns all possible paths for SSG pre-rendering.- Catch-all with
/404/: The 404 folder is special-cased to the*pattern.
Pattern Matching
The heart of any router is matching a URL path against a route pattern. Since the file structure uses [param] folders for dynamic segments, the matcher checks for brackets:
function matchPath(pattern: string, path: string): MatchResult {
// Handle catch-all (404 folder becomes * route)
if (pattern === '*') return { matched: true, params: {} };
const patternParts = pattern.split('/').filter(Boolean);
const pathParts = path.split('/').filter(Boolean);
// Different lengths can't match
if (patternParts.length !== pathParts.length) {
return { matched: false, params: {} };
}
const params: RouteParams = {};
for (let i = 0; i < patternParts.length; i++) {
const patternPart = patternParts[i];
const pathPart = pathParts[i];
if (patternPart.startsWith('[') && patternPart.endsWith(']')) {
// Dynamic segment - extract param name from brackets
params[patternPart.slice(1, -1)] = pathPart;
} else if (patternPart !== pathPart) {
// Static segment doesn't match
return { matched: false, params: {} };
}
}
return { matched: true, params };
}
This handles:
- Static routes:
/blog/matches/blog - Dynamic routes:
/project/[slug]/matches/project/portfoliowith{ slug: 'portfolio' } - Catch-all:
*matches anything (mapped from the/404/folder)
What it doesn't handle (and I don't need): optional segments, wildcards in the middle of routes, or query string parsing. Those would be easy to add, but why add code I won't use?
Lazy Loading and Bundle Splitting
This is where things get interesting. Every route component is wrapped in lazy():
component: lazy(() => import('./pages/project/[slug]/+Page'));
When Vite builds the app, it sees these dynamic imports and creates separate chunks. The result:
When a user lands on the home page, they download the core bundle plus the home page chunk. Navigate to a project? That chunk loads on demand. The user never downloads code for pages they don't visit.
The Router component wraps lazy components in Suspense:
export function Router({ fallback }: RouterProps) {
const { matchedRoute } = useRouter();
const Component = matchedRoute.component;
return (
<ViewTransition>
<Suspense fallback={fallback ?? <LoadingFallback />}>
<Component />
</Suspense>
</ViewTransition>
);
}
Suspense catches the promise thrown by the lazy component and shows a fallback while the chunk loads. On fast connections, users rarely see the fallback. On slow connections, they see a loading indicator instead of a blank screen.
Navigation with startTransition
React 19's startTransition is the secret sauce for smooth navigation. Here's the navigate function:
const navigate = useCallback(
(to: string) => {
const { path: newPath, hash: newHash } = parseUrl(to);
if (to.startsWith('#') || newPath === path) {
// Hash-only navigation - just scroll
window.history.pushState({}, '', to);
scrollToHash(newHash);
return;
}
// Update browser history
window.history.pushState({}, '', to);
// Wrap state updates in startTransition
startTransition(() => {
setPath(newPath);
setHash(newHash);
// Find and set the matched route...
});
// Scroll to top or hash
if (newHash) {
requestAnimationFrame(() => scrollToHash(newHash));
} else {
window.scrollTo(0, 0);
}
},
[routes, path],
);
Why startTransition? Without it, clicking a link would immediately update state, potentially causing a flash of loading state before the new route's component loads. With startTransition, React keeps showing the current page until the new content is ready—or until the transition takes too long, at which point it shows the Suspense fallback.
The user experience: click a link, see a brief transition animation (thanks to ViewTransition), and the new page appears. No loading spinners for fast navigations.
ViewTransition API
The ViewTransition API is relatively new, but it's a game-changer for SPA navigation. React 19.3 includes a <ViewTransition> component that hooks into this API:
return (
<ViewTransition>
<Suspense fallback={fallback}>
<Component />
</Suspense>
</ViewTransition>
);
When the route changes, the browser:
- Screenshots the current state
- Renders the new state
- Crossfades between them
The default transition is a simple fade, but you can customize it with CSS:
::view-transition-old(root) {
animation: fade-out 150ms ease-out;
}
::view-transition-new(root) {
animation: fade-in 150ms ease-in;
}
For browsers that don't support ViewTransition yet, it degrades gracefully—navigation still works, just without the animation.
Hash Navigation
Hash links (#section) need special handling. They shouldn't trigger a full navigation—just scroll to the element:
function scrollToHash(hash: string) {
if (!hash) return;
const id = hash.replace('#', '');
const element = document.getElementById(id);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}
}
The navigate function checks if the destination is hash-only or same-page:
const isHashOnly = to.startsWith('#');
const isSamePage = newPath === path || newPath === '';
if (isHashOnly || isSamePage) {
window.history.pushState({}, '', to);
setHash(newHash);
scrollToHash(newHash);
return; // Don't trigger route change
}
This means /blog/some-post#conclusion navigates to the blog post, then scrolls to the conclusion. And clicking #conclusion from that page just scrolls—no re-render.
The Link Component
A router isn't complete without a Link component that intercepts clicks:
export function Link({ to, children, onClick, ...props }: LinkProps) {
const { navigate, prefetch } = useRouter();
const handleClick = (e: MouseEvent<HTMLAnchorElement>) => {
// Allow default for modifier keys (open in new tab, etc.)
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) {
return;
}
e.preventDefault();
onClick?.(e);
navigate(to);
};
const handleMouseEnter = () => {
prefetch(to);
};
return (
<a
href={to}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
{...props}
>
{children}
</a>
);
}
The important detail: we still render an <a> tag with a real href. This means:
- Right-click → "Open in new tab" works
- Cmd/Ctrl+click opens a new tab
- The link is crawlable by search engines
- If JavaScript fails to load, the link still works
Progressive enhancement for free.
Prefetching on Hover
Notice the onMouseEnter handler above? That's route prefetching. When a user hovers over a link, we start loading the target route's code before they click.
const prefetchedPaths = useRef(new Set<string>());
const prefetch = useCallback(
(to: string) => {
// Skip external URLs and hash-only links
if (to.startsWith('http://') || to.startsWith('https://')) return;
if (to.startsWith('#')) return;
const { path: targetPath } = parseUrl(to);
if (prefetchedPaths.current.has(targetPath)) return;
for (const route of routes) {
const result = matchPath(route.path, targetPath);
if (result.matched) {
prefetchedPaths.current.add(targetPath);
// Prefetch the lazy component
const component = route.component as any;
if (typeof component.preload === 'function') {
component.preload();
}
break;
}
}
},
[routes],
);
The magic is in component.preload(). When you wrap a component with React.lazy(), the resulting object has a preload method that triggers the dynamic import without rendering the component. The browser fetches the chunk, and by the time the user clicks, it's already cached.
Routes can also define a loader function for data fetching. If present, prefetch calls the loader and caches the promise:
// In the route definition
{
path: '/blog/:slug',
component: lazy(() => import('./pages/blog/[slug]/+Page')),
loader: async ({ params }) => {
return fetchBlogPost(params.slug);
},
}
// In the prefetch function
if (typeof route.loader === 'function') {
const loaderPromise = route.loader({ params: result.params });
loaderCache.current.set(targetPath, loaderPromise);
}
The component can then read from the cache when it renders. Data and code load in parallel during hover, so navigation feels instant.
This is the kind of optimization that frameworks like Next.js do automatically. Building it yourself takes about 30 lines of code and gives you complete control over when and what to prefetch.
SSG Hydration
When the page is pre-rendered (covered in Part 3), the router needs to know the initial path. The RouterProvider accepts an initialPath prop:
export function RouterProvider({
children,
initialPath,
initialParams = {},
routes,
}: RouterProviderProps) {
const [path, setPath] = useState(() => {
if (initialPath) return parseUrl(initialPath).path;
if (typeof window !== 'undefined') {
return window.location.pathname;
}
return '/';
});
// ...
}
During SSG, we pass the path being rendered. During hydration, the client picks up window.location.pathname. The two should match—if they don't, you've got a hydration mismatch bug.
Browser History
The router also handles the back/forward buttons:
useLayoutEffect(() => {
const handlePopState = () => {
const newPath = window.location.pathname;
const newHash = window.location.hash;
startTransition(() => {
setPath(newPath);
setHash(newHash);
// Match route and update state...
});
if (newHash) {
scrollToHash(newHash);
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [routes]);
popstate fires when the user clicks back/forward. We read the new path from window.location and update state accordingly. Same startTransition treatment for smooth transitions.
Why useLayoutEffect instead of useEffect? When the browser fires popstate, it updates window.location synchronously. With useEffect, there's a gap between the URL changing and React processing the route—the user might see the old page with the new URL for a frame. useLayoutEffect runs before the browser paints, ensuring the event listener is attached immediately and state updates happen before any visual inconsistency can appear.
What I'd Add for a Larger App
This router is purpose-built for a portfolio. For a larger application, I'd consider:
- Scroll restoration: Remember scroll position when navigating back
- Nested routes: Child routes that render within parent layouts
- Route guards: Redirect unauthenticated users, etc.
None of these are hard to add. But I don't need them, so they don't exist. That's the point.
Alternatives I Considered
React Router v7: The obvious choice. Excellent documentation, huge community, handles every edge case. But it's 8x the size of what I built, and I'd use maybe 10% of its features.
TanStack Router: Type-safe routing with great DevTools. Impressive engineering. Also more than I need, and the learning curve felt steep for such a simple use case.
Wouter: A minimal React router at ~1.5KB. Honestly, this would have been a reasonable choice. I went custom because I wanted the learning experience and tight ViewTransition integration.
The Full Picture
The complete router is about 250 lines of TypeScript across four files:
types.ts- Type definitionscontext.tsx- RouterProvider and hooksRouter.tsx- The Router component with Suspense/ViewTransitionLink.tsx- The Link component
That's it. No build plugins, no babel transforms, no special file naming conventions. Just React components and the History API.