At my day job, routing logic has been one of those “death by a thousand cuts” situations. I’ve seen a range of patterns across different domains – and most aren’t type-safe. It’s too easy to fat-finger route segments or get the params wrong, and there’s little in the way of guardrails when working across teams.
After reading a great article by Sahaj Jain, I wanted something better. Something type-safe, explicit, and intuitive to use.
What I wanted
I don’t need anything fancy. I just want to:
- Define routes in one place
- Get type safety for route params
- Not worry about typos or forgetting a param when I’m deep in a feature
I ended up writing a little createRouter helper that wraps the basics from react-router-dom, but with a nice DX and strong types. Here’s what I ended up with.
First, we create a createRouter helper to cobble together the various methods we need to access in the application.
import { generatePath, useParams } from 'react-router-dom';
/**
* Creates a type-safe router based on a route map.
* @param routes A map of route keys to path templates (e.g., "/blog/posts/:postId")
*/
export const createRouter = <
P extends Record<string, Record<string, string> | undefined>
>(
routes: Record<keyof P, string>
) => {
type RouteKeys = keyof P;
return {
/**
* Gets the raw path pattern (with `:param` placeholders).
* @example blogRouter.getPattern('post') -> "/blog/posts/:postId"
*/
getPattern: <K extends RouteKeys>(key: K): string => routes[key],
/**
* Generates a concrete path from a key and params.
* @example blogRouter.to('post', { postId: '123' }) -> "/blog/posts/123"
*/
to: <K extends RouteKeys>(key: K, params?: P[K]): string =>
generatePath(routes[key], params as any),
/**
* Gets typed route params for a route key using `useParams()`.
* @example const { postId } = blogRouter.useParams('post');
*/
useParams: <K extends RouteKeys>(_: K): P[K] =>
useParams() as P[K],
};
};
Then, define your route templates and their expected params:
const BLOG_ROUTES = {
home: '/blog',
posts: '/blog/posts',
post: '/blog/posts/:postId',
postEdit: '/blog/posts/:postId/edit',
newPost: '/blog/new',
} as const;
type RouteParams = {
home: undefined;
posts: undefined;
post: { postId: string };
postEdit: { postId: string };
newPost: undefined;
};
Then you create the router:
export const blogRouter = createRouter<RouteParams>(BLOG_ROUTES);
Now you can generate paths and access params without worrying about mismatches:
// Generates: "/blog/posts/123/edit"
blogRouter.to('postEdit', { postId: '123' });
// Inside a component
const { postId } = blogRouter.useParams('postEdit');
This also works nicely when you’re setting up your app’s routes:
<Routes>
<Route path={blogRouter.getPattern('home')} element={<HomePage />} />
<Route path={blogRouter.getPattern('posts')} element={<PostList />} />
<Route path={blogRouter.getPattern('post')} element={<PostDetail />} />
<Route path={blogRouter.getPattern('postEdit')} element={<EditPost />} />
<Route path={blogRouter.getPattern('newPost')} element={<NewPost />} />
</Routes>
This pattern has already paid off. It removes friction, improves safety, and just feels cleaner to work with. I’m planning to apply it across other domains in the app too.
Big thanks again to Sahaj Jain for the inspiration.
If you’re also tired of fragile route management in React, give this approach a try.