When working with dynamic routes, especially in frameworks like Next.js, React Router, or Express, typing them correctly can be a bit tricky but is crucial for maintainability and catching bugs early. Dynamic routes are those where parts of the URL path are variable—like user IDs, product slugs, or dates—and typing them properly means ensuring your code knows exactly what kind of data to expect and handle.
Typing dynamic routes well helps prevent runtime errors, improves developer experience with better autocomplete and type checking, and makes your codebase easier to understand and refactor. Let me walk you through how I approach typing dynamic routes, including practical tips, common pitfalls, and some real-world examples.
Dynamic routes typically involve URL segments that change based on user input or data. For example, a blog post URL might look like /posts/[postId] where postId is dynamic. When you type these routes, you’re essentially defining the shape of the parameters that your route handler or page component expects.
Without proper typing, you risk:
Typing dynamic routes is particularly important in TypeScript projects, where you want to leverage static type checking to catch errors early.
The core idea is to define a type or interface that represents the route parameters, then use that type wherever you access those parameters. This applies whether you’re working on the frontend (React Router, Next.js) or backend (Express, Koa).
Next.js uses file-based routing, where dynamic segments are wrapped in square brackets. For example, pages/posts/[postId].tsx corresponds to a route like /posts/123. To type the postId parameter, you typically use the useRouter hook from next/router:
import { useRouter } from 'next/router';
function PostPage() {
const router = useRouter();
const { postId } = router.query;
// postId is typed as string | string[] | undefined by default
// We usually want to narrow this down:
if (typeof postId !== 'string') {
return <p>Invalid post ID</p>;
}
return <div>Post ID is {postId}</div>;
}
By default, router.query types dynamic parameters as string | string[] | undefined, because Next.js supports catch-all routes and optional parameters. This means you often need to manually narrow the type, which can get repetitive.
To improve this, you can create a custom hook or utility type that enforces the expected shape of your parameters:
import { useRouter } from 'next/router';
type PostPageParams = {
postId: string;
};
function useTypedRouter() {
const router = useRouter();
const query = router.query as unknown as PostPageParams;
return { ...router, query };
}
function PostPage() {
const { query } = useTypedRouter();
const { postId } = query;
return <div>Post ID is {postId}</div>;
}
This approach trades off some type safety (casting with as) for developer ergonomics. In production, you might want to add runtime checks or validation to ensure the parameters truly match your expectations.
On the backend, Express routes often look like:
app.get('/users/:userId', (req, res) => {
const userId = req.params.userId;
// userId is typed as string by default
});
If you’re using TypeScript, you can type the request parameters explicitly by extending Express’s Request interface:
import { Request, Response } from 'express';
interface UserParams {
userId: string;
}
app.get('/users/:userId', (req: Request<UserParams>, res: Response) => {
const { userId } = req.params;
// userId is now strongly typed as string
res.send(`User ID is ${userId}`);
});
This explicit typing improves code clarity and helps avoid mistakes like treating userId as a number without conversion.
as too liberally can hide bugs. It’s better to use type guards or validation libraries.undefined values can cause crashes.Typing dynamic routes itself doesn’t impact runtime performance since it’s a compile-time feature. However, how you handle dynamic routes can affect app performance:
zod or yup can help validate and parse parameters efficiently.Dynamic routes often expose user input, so you need to be careful:
undefined or array types.| Framework | Typing Approach | Pros | Cons |
|---|---|---|---|
| Next.js | Use useRouter with manual type narrowing or custom hooks |
Integrates well with file-based routing; good autocomplete | Requires manual type assertions; no built-in param typing |
| React Router | Use generics on useParams<T>() |
Strong typing with generics; easy to define param types | Less intuitive for catch-all or optional params |
| Express.js | Extend Request interface with param types |
Clear and explicit; works well with TypeScript | Requires boilerplate for each route; no runtime validation |
Imagine you’re building an e-commerce site with product pages at /products/[category]/[productId]. You want to type these parameters so that your page component knows exactly what to expect:
type ProductPageParams = {
category: string;
productId: string;
};
function ProductPage() {
const router = useRouter();
const { category, productId } = router.query as unknown as ProductPageParams;
if (!category || !productId) {
return <p>Loading...</p>;
}
// Validate category against allowed categories
const allowedCategories = ['electronics', 'clothing', 'books'];
if (!allowedCategories.includes(category)) {
return <p>Invalid category</p>;
}
return <div>
<h1>Product {productId} in {category}</h1>
</div>;
}
Here, the typing helps you catch missing parameters, and the runtime check ensures the category is valid before fetching data or rendering.
In backend APIs, you might do something similar but also parse productId as a number or UUID, and reject invalid inputs early.
Overall, typing dynamic routes well is a balance between static guarantees and runtime validation, and it pays off by making your code safer and easier to maintain.