File-based routing with React Router

Next.js-inspired file-based routing for client-side React with React Router and Vite

September 08, 2021

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.