File-based routing with React Router
Next.js-inspired file-based routing for client-side React with React Router and Vite
⌁ Check out Generouted if you want to use client-side file-based routing. It provides more features than what’s covered here, multiple frameworks support and multiple routers integrations!
Most React applications involve a routing setup to allow user navigation between different pages. There are two common ways to handle routing in React depending on your rendering method.
You probably know React Router, a widely used library for declarative/component-based client-side routing. With server-side rendering, React frameworks like Next.js offer an alternative approach called file-based routing.
In a traditional client-side React routing setup with React router, you’ll declare your routes within a <Switch />
component by adding a <Route/>
component for each page, specifying a path and a corresponding component to be rendered as a page. It works fine but it lacks universal convention. Also when declaring many routes it becomes hard to follow, at least in my experience.
With file-based routing you don’t import page components manually. Instead you define all your routes by adding/removing/renaming files within a directory called pages
by convention. Each file inside the pages
directory will represent a route in your application. That makes it easier to visualize and manage as the pages
directory structure reflects all existing routes. You can also have dynamic routes with special file names and nested routes with sub-directories. We’ll discuss some of the file-based routing common patterns shortly.
Today I want to share with you how I’m using React Router with Vite to build a file-based routing for client-side React applications inspired by Next.js file-based routing conventions, in 30 lines of code and one awesome Vite feature.
Before we get started let’s talk about why you might prefer using file-based routing over the traditional component-based approach with client-side routing:
- It’s a universal routing convention whether using Next.js or just React
- Routes are automatically updated by adding/removing/renaming files at the
pages
directory - Routes are represented in an easy-to-follow file tree and grouped by nesting
- It only needs a one-time straight forward setup that is flexible and extendable
- It’s easier to migrate when switching from or to Next.js
Don’t worry if you’ve never used Next.js or file-based routing previously, it’s quite easy to grasp. Basically each file inside the pages
directory will represent a route by its name and its exported component.
Therefore naming files within the pages
directory should be done according to the URL/path you want to give to the route. Let’s say you want to add a page at /about
path, you just need a file inside the pages
directory at pages/about.tsx
and the component to be rendered with this path will be exported from this same file.
Here are some examples of file paths and their corresponding URLs for commonly used patterns in file-based routing. We’ll be using src/pages
as the pages
directory:
Index routes
src/pages/index.tsx
→/
src/pages/posts/index.tsx
→/posts
Nested routes
src/pages/posts/topic.tsx
→/posts/topic
src/pages/settings/profile.tsx
→/settings/profile
Dynamic routes
src/pages/posts/[slug].tsx
→/posts/:slug
src/pages/[user]/settings.tsx
→/:user/settings
src/pages/posts/[...all].tsx
→/posts/*
In the following sections we’ll be covering all the patterns above.
Getting started
First of all we need to setup a React project with Vite.
You can scaffold a Vite project with React and TypeScript by using --template react-ts
:
npm init vite@latest my-react-app -- --template react-ts
Then add react-router-dom
as a dependency:
npm install react-router-dom
Configuring routes
Let’s now setup the file-based routing that will allow us to get the routes from the files in the pages
directory. We only have to make this setup once. Then we can update the routes by simply adding/removing/renaming files inside the pages
directory.
In order to import all pages modules we’ll be using a glob import API that comes with Vite. It works by specifying a pattern to find certain type of files. In our case we want to find/match React components inside the pages
directory, so we’ll specify a pattern to look for files only in pages
directory that ends with .tsx
extension. The glob matching is done via fast-glob
, if you want to update the pattern we’re using or use different patterns check its documentation for supported glob patterns.
The glob matching will give us an object with each matching module path as a key and its corresponding exports as value. Then we need to transform this object into an array of objects each having path
and component
properties to be used with the <Route />
component.
We’ll be using import.meta.globEager
to import all modules directly:
// src/routes.tsx const ROUTES = import.meta.globEager('/src/pages/**/[a-z[]*.tsx')
The result of this pattern will only include files starting with a lowercase letter a-z
or an opening square bracket [
and ending with .tsx
file extension. You can modify the pattern above to support .jsx
files too by replacing .tsx
with .{tsx,jsx}
.
That will give us an object as we mentioned earlier with each matching module path as a key and its corresponding exports as value:
ROUTES = { '/src/pages/index.tsx': { default: ƒ Index(), ... }, '/src/pages/posts/index.tsx': { default: ƒ PostsIndex(), ... }, '/src/pages/posts/[slug].tsx': { default: ƒ PostsSlug(), ... }, ... }
We can now use the map
method to iterate over the ROUTES
object keys and create a new array of objects each having a path and its corresponding default export
as a component:
const routes = Object.keys(ROUTES).map((route) => { const path = route .replace(/\/src\/pages|index|\.tsx$/g, '') .replace(/\[\.{3}.+\]/, '*') .replace(/\[(.+)\]/, ':$1') return { path, component: ROUTES[route].default } })
Then we transform each file path to its corresponding URL by using replace
method to:
- Remove
/src/pages
,index
and.tsx
from each path - Replace
[...param]
patterns with*
- Replace
[param]
patterns with:param
The above code will result in the following array after mapping:
routes = [ { path: '/', component: ƒ Index() }, { path: '/posts', component: ƒ PostsIndex() }, { path: '/posts/:slug', component: ƒ PostsSlug() }, ... ]
That was a fairly simple implementation for a flat routes
array. You can also check React Router’s static route config package or their route config example to see different approaches.
And finally we can map
over the routes
array to render each route:
export const Routes = () => { return ( <Switch> {routes.map(({ path, component: Component = Fragment }) => ( <Route key={path} path={path} component={Component} exact={true} /> ))} </Switch> ) }
Now, by simply importing this <Routes />
component in your application entry, wrapped by a <BrowserRouter />
you’ll get the file-based client-side routing working with React.
Preserved files
Next.js comes with some preserved files like pages/_app
and pages/404
. Although this part is optional for both implementation and usage, we’ll be adding pages/_app
and pages/404
as they’re often used. Thus, we also stick to Next.js conventions.
We’ll use another glob pattern to get those two files and set them as preserved files:
const PRESERVED = import.meta.globEager('/src/pages/(_app|404).tsx')
The result will be similar to ROUTES
in structure but we’ll transform it slightly:
PRESERVED = { '/src/pages/_app.tsx': { default: ƒ App(), ... }, '/src/pages/404.tsx': { default: ƒ NotFound(), ... } }
We’ll just format the PRESERVED
object keys, and set the corresponding default export
component as value for each key:
const preserved = Object.keys(PRESERVED).reduce((preserved, file) => { const key = file.replace(/\/src\/pages\/|\.tsx$/g, '') return { ...preserved, [key]: PRESERVED[file].default } }, {})
And the resulting object will be as following:
preserved = { '_app': ƒ App(), '404': ƒ NotFound() }
Finally we check if preserved files exist to be used. If it doesn’t or if there’s no default export we’ll render a <Fragment />
instead:
export const Routes = () => { const App = preserved?.['_app'] || Fragment const NotFound = preserved?.['404'] || Fragment return ( <App> <Switch> {routes.map(({ path, component: Component = Fragment }) => ( <Route key={path} path={path} component={Component} exact={true} /> ))} <Route path="*" component={NotFound} /> </Switch> </App> ) }
Final code
Putting all the pieces from the above sections together it should be as following:
// src/routes.tsx import React, { Fragment } from 'react' import { Switch, Route } from 'react-router-dom' const PRESERVED = import.meta.globEager('/src/pages/(_app|404).tsx') const ROUTES = import.meta.globEager('/src/pages/**/[a-z[]*.tsx') const preserved = Object.keys(PRESERVED).reduce((preserved, file) => { const key = file.replace(/\/src\/pages\/|\.tsx$/g, '') return { ...preserved, [key]: PRESERVED[file].default } }, {}) const routes = Object.keys(ROUTES).map((route) => { const path = route .replace(/\/src\/pages|index|\.tsx$/g, '') .replace(/\[\.{3}.+\]/, '*') .replace(/\[(.+)\]/, ':$1') return { path, component: ROUTES[route].default } }) export const Routes = () => { const App = preserved?.['_app'] || Fragment const NotFound = preserved?.['404'] || Fragment return ( <App> <Switch> {routes.map(({ path, component: Component = Fragment }) => ( <Route key={path} path={path} component={Component} exact={true} /> ))} <Route path="*" component={NotFound} /> </Switch> </App> ) }
And the application entry will be updated as following:
// src/main.tsx import React, { StrictMode } from 'react' import { render } from 'react-dom' import { BrowserRouter } from 'react-router-dom' import { Routes } from './routes' render( <StrictMode> <BrowserRouter> <Routes /> </BrowserRouter> </StrictMode>, document.getElementById('root'), )
Now you only need to add/remove/rename files at the src/pages
directory to manage your routes.
And that’s how the directory structure should look like after starter code files cleanup and having couple of routes in the src/pages
directory:
. ├── index.html ├── package.json ├── package-lock.json ├── src │ ├── main.tsx │ ├── pages │ │ ├── about.tsx │ │ ├── index.tsx │ │ └── posts │ │ └── [slug].tsx │ ├── routes.tsx │ └── vite-env.d.ts ├── tsconfig.json └── vite.config.ts
Additional info ⌁
Adding/removing/renaming routes doesn’t need a server restart, Vite automatically hot reloads/updates when pages
directory is updated as the routes import changes.
You can access a page dynamic parameter by using React Router’s useParams()
hook. The parameter’s name corresponds to what’s inside of the square brackets of the file name:
// src/pages/posts/[slug].tsx import React from 'react' import { useParams } from 'react-router-dom' export default function Post() { const { slug } = useParams<{ slug: string }>() return <h1>Post: {slug}</h1> }
What’s next
If you want to see full project example of file-based routing with client-side React, check out Render — a React template I’ve setup while trying pre-rendering with Vite. You can find Render’s routing configuration at src/config/routes.tsx
. On this configuration you’ll also see that pages can export a meta.scope
variable to set a private
or public
scope.
I’ll be updating Render to enable automatic route-based code-splitting and lazy-loading with this file-based routing we’ve setup, I’m planning to write about that in a future post. I got pre-rendering and file-based routing working together with Vite. And server-side rendering should work as well with very few additions.
Note that this concept is also applicable to other JavaScript frameworks than React if you’re using Vite, as it’s what powers this file-base routing.
Vite is a very powerful tool that opens a lot of possibilities as you can build many features on top of it. I only scratched the surface but you can go further and build a framework-like experience.
I would love to hear what you think about this file-based routing approach, feel free to leave a comment on the discussion. If you have questions or got stuck at some point I’ll be happy to help.
Share this post if you find it useful and stay tuned for upcoming posts.