Next.js uses file-system based routing, meaning you can use folders and files to define routes. Basically, you don’t need to define routes, and if you access the app by a folder and file path, you can get the component. React Router can also do this. Instead of doing the following:

<Route path="/demo/Button" element={<Button />} />
<Route path="/demo/Card" element={<Card />} />
//...

We can load the component by path:

<Route path="/demo/:componentName" element={<ComponentAtPath />} />

By following a convention in routing, we can save a lot of configuration. So, convention over configuration for the win. Here is how the ComponentAtPath is implemented:

import { lazy, Suspense } from "react";
import { Navigate, useParams } from "react-router-dom";

export const ComponentAtPath = () => {
  const { componentName } = useParams();
  const path = `./demo/${componentName}`;
  const Component = lazy(() =>
    import(path).catch(() => ({ default: () => <Navigate to="/404" /> }))
  );

  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Component />
    </Suspense>
  );
};

If I have more components, I don’t need to add more route entries. This is easy for me to showcase my React prototypes. So I use the following to create a list of my components:

export const ComponentList = () => {
  const components = [
    "Button, A simple button component",
    "Card, A simple card component",
    "Dialog, Dialog which does not exist",
  ];

  return (
    <div>
      <h2>Select a Component</h2>
      <ul>
        {components.map((item) => {
          const [componentName, description] = item.split(",");
          return (
            <li key={componentName}>
              <Link to={`/demo/${componentName}`}>
                {description || componentName}
              </Link>
            </li>
          );
        })}
      </ul>
    </div>
  );
};

export function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="./demo/:componentName" element={<ComponentAtPath />} />
        <Route path="404" element={<h1>Page Not Found</h1>} />
        <Route path="" element={<ComponentList />} />
      </Routes>
    </BrowserRouter>
  );
}

This works in development time, but when you deploy to production, it no longer works. In production, tools like Vite analyze and bundle your code ahead of time. When you use a dynamic string for import(path), the bundler can’t statically analyze and pre-bundle those components because the path is dynamic. This means that the component is not included in the build output, causing it to fail when trying to load it at runtime. To solve this problem, we need to ensure that the dynamic import path is resolvable at build time. One way to solve this is to use static imports for known components and use dynamic imports only for specific cases where the component name is known upfront. We can also try to organize our components in a way that allows the bundler to optimize them correctly. Here is the revised code:

// Define a static glob pattern to match all components
const componentFiles = import.meta.glob(`./demo/*.tsx`);

export const ComponentAtPath = () => {
  const { componentName } = useParams();

  // Dynamically select the component based on componentName
  const componentPath = `./demo/${componentName}.tsx`;

  // Check if the component is available in the glob mapping
  const Component = lazy(() => {
    if (componentFiles[componentPath]) {
      return componentFiles[componentPath]().then((module) => ({
        default: module.default,
      }));
    } else {
      return Promise.resolve({ default: () => <Navigate to="/404" /> });
    }
  });

  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Component />
    </Suspense>
  );
};

The full source code can be found here. The demo of the app is as follows: