Routing

Modern.js's routing is based on React Router 6 and provides multiple types of routing modes. According to different entry types, routing is divided into three modes: Conventional Routing, Self-controlled Routing, and Other.

NOTE

The routing mentioned in this section refers to client-side routing, i.e., SPA routing.

Conventional Routing

With routes/ as the convention for entry points, Modern.js automatically generates the corresponding routing structure based on the file system.

Modern.js supports the popular conventional routing mode in the industry: Nested Routing. When using nested routing, the page's routing corresponds to the UI structure, and we will introduce this routing mode in detail.

/user/johnny/profile                  /user/johnny/posts
+------------------+                  +-----------------+
| User             |                  | User            |
| +--------------+ |                  | +-------------+ |
| | Profile      | |  +------------>  | | Posts       | |
| |              | |                  | |             | |
| +--------------+ |                  | +-------------+ |
+------------------+                  +-----------------+

Routing File Convention

Under the routes/ directory, the directory name is mapped to the route URL. Modern.js has two file conventions, layout.[jt]sx and page.[jt]sx (abbreviated as.tsx). These two files determine the layout structure of the application. layout.tsx is used as the layout component, and page.tsx acts as the content component, which is the leaf node of the entire route (a route has only one leaf node and must end with a leaf node).

For example, the following directory structure:

.
└── routes
    ├── page.tsx
    └── user
        └── page.tsx

This will generate the following two routes:

  • /
  • /user

When layout.tsx is added, assuming the following directory:

INFO

Here, routes/layout.tsx will be used as the layout component for all components under the / route, and routes/user/layout.tsx will be used as the layout component for all route components under the /user route.

.
└── routes
    ├── layout.tsx
    ├── page.tsx
    └── user
        ├── layout.tsx
        └── page.tsx

When the route is /, the following UI layout will be displayed:

<Layout>
  <Page />
</Layout>

Similarly, routes/user/layout.tsx will be used as the layout component for all components under the /user route. When the route is /user, the following UI layout will be displayed:

<Layout>
  <UserLayout>
    <UserPage />
  </UserLayout>
</Layout>

Layout

The <Layout> component refers to all layout.tsx files under the routes/ directory. They represent the layout of the corresponding route segment and use <Outlet> to represent child components.

routes/layout.tsx
import { Link, Outlet, useLoaderData } from '@modern-js/runtime/router';

export default () => {
  return (
    <>
      <Outlet></Outlet>
    </>
  );
};
NOTE

<Outlet> is a new API in React Router 6. For more details, please refer to Outlet.

To simplify the introduction of the relationship between <Layout> and <Outlet>, the following file directory is used as an example:

.
└── routes
    ├── blog
    │   └── page.tsx
    ├── layout.tsx
    ├── page.tsx
    └── user
        ├── layout.tsx
        └── page.tsx
  1. When the route is /, the <Outlet> in routes/layout.tsx represents the component exported in routes/page.tsx, generating the following UI structure:
<Layout>
  <Page />
</Layout>
  1. When the route is /blog, the <Outlet> in routes/layout.tsx represents the component exported in routes/blog/page.tsx, generating the following UI structure:
<Layout>
  <BlogPage />
</Layout>
  1. When the route is /user, the <Outlet> in routes/layout.tsx represents the component exported in routes/user/layout.tsx. The <Outlet> in routes/user/layout.tsx represents the component exported in routes/user/page.tsx, generating the following UI structure:
<Layout>
  <UserLayout>
    <UserPage />
  </UserLayout>
</Layout>

In summary, if there is a layout.tsx file under the sub-route's file directory, the <Outlet> in the parent layout.tsx will represent the layout.tsx in the sub-route file directory. Otherwise, it will represent the page.tsx in the sub-route file directory.

Page

All routes should end with the <Page> component. If the developer introduces the <Outlet> component in the page.tsx file, there will be no effect.

Config

Each Layout,$ or Page file can define its own config file, such as page.config.ts. In this file, we have an conventinal on a named export called handle, which you can define any properties:

routes/blog/page.config.ts
export const handle = {
  breadcrumbName: 'profile',
};

These properties as defined are available via the useMatches hook:

routes/layout.ts
export default () => {
  const matches = useMatches();
  const breadcrumbs = matches.map(
    matchedRoute => matchedRoute?.handle?.breadcrumbName,
  );
  return <Breadcrumb names={breadcrumbs}></Breadcrumb>;
};

Dynamic Routing

Routes generated from file directories named with [] will be handled as dynamic routes. For example, the following file directory:

└── routes
    ├── [id]
    │   └── page.tsx
    ├── blog
    │   └── page.tsx
    └── page.tsx

The routes/[id]/page.tsx file will be converted to the /:id route. Except for the exact matching /blog route, all other /xxx routes will match this route.

In the component, you can use useParams to get the corresponding named parameter.

In the loader, params will be passed as the input parameter of the loader function, and you can get the parameter value through params.xxx.

Dynamic Optional Routing

Routes generated from file directories named with [$] will be treated as dynamic optional routes. For example, the following file directory:

└── routes
    ├── user
    │   └── [id$]
    │       └── page.tsx
    ├── blog
    │   └── page.tsx
    └── page.tsx

The routes/user/[id$]/page.tsx file will be converted to the /user/:id? route. All routes under /user will match this route, and the id parameter is optional. This route is usually used to distinguish between creation and editing.

In the component, you can use useParams to get the corresponding named parameter.

In the loader, params will be passed as the input parameter of the loader function, and you can get the parameter value through params.xxx.

Catch-all Routing

If you create a $.tsx file under the routes directory, it will be treated as the catch-all routing component. When there is no matching route, this component will be rendered.

NOTE

$.tsx can be considered as a special page route component. When there is a layout.tsx file in the current directory, $.tsx will be rendered as a child component of layout.

For example, the following directory structure:

└── routes
    ├── $.tsx
    ├── blog
    │   └── page.tsx
    └── page.tsx

When accessing any path that does not match(For example /blog/a), the routes/$.tsx component will be rendered, because there is layout.tsx here, the rendered UI is as follows.

<RootLayout>
  <BlogLayout>
    <$></$>
  </BlogLayout>
</RootLayout>

If you want access to /blog to also match the blog/$.tsx file, you need to delete the blog/layout.tsx file in the same directory and make sure that there are no other subroutes under blog.

As same, you can use useParams in $.tsx to capture the remaining parts of the URL.

$.tsx
import { useParams } from '@modern-js/runtime/router';
// When the path is `/aaa/bbb`
const params = useParams();
params['*']; // => 'aaa/bbb'

No-path Layout

When the directory name starts with __, the directory name will not be converted to an actual route path. For example, the following file directory:

.
└── routes
    ├── __auth
    │   ├── layout.tsx
    │   ├── login
    │   │   └── page.tsx
    │   └── signup
    │       └── page.tsx
    ├── layout.tsx
    └── page.tsx

Modern.js will generate two routes, /login and /signup. The __auth/layout.tsx component will serve as the layout component for login/page.tsx and signup/page.tsx, but __auth will not be a route path segment.

This feature is very useful when you need to create independent layouts for certain types of routes or want to classify routes.

No Layout

In some cases, the project requires complex routing, but these routes do not have independent UI layouts. If you create routes like ordinary file directories, it will result in deep directory levels.

Therefore, Modern.js supports using . to separate route segments instead of file directories. For example, when you need /user/profile/2022/edit, you can directly create the following files:

└── routes
    ├── user.profile.[id].edit
    │      └── page.tsx
    ├── layout.tsx
    └── page.tsx

When accessing the route, you will get the following UI layout:

<RootLayout>
  <UserProfileEdit /> // routes/user.profile.[id].edit/page.tsx
</RootLayout>

(WIP)Loading

In each directory under routes/, developers can create a loading.tsx file that exports a <Loading> component by default.

When this component and the layout component exist in the route directory, the <Loading> component will be used as the fallback UI when switching routes in this sub-route. For example, the following file directory:

.
└── routes
    ├── blog
    │   ├── [id]
    │   │   └── page.tsx
    │   └── page.tsx
    ├── layout.tsx
    ├── loading.tsx
    └── page.tsx

When defining loading.tsx, it is equivalent to the following layout:

When the route is /:

<Layout>
  <Suspense fallback={<Loading />}>
    <Page />
  </Suspense>
</Layout>

When the route is /blog:

<Layout>
  <Suspense fallback={<Loading />}>
    <BlogPage />
  </Suspense>
</Layout>

When the route is /blog/123:

<Layout>
  <Suspense fallback={<Loading />}>
    <BlogIdPage />
  </Suspense>
</Layout>
INFO

When the Layout component does not exist in the directory, the Loading component in that directory will not take effect. Modern.js recommends having a root Layout and root Loading.

When the route jumps from / to /blog, if the JS Chunk of the blog/page component has not been loaded yet, the UI of the component exported in loading.tsx will be displayed first.

Similarly, when the route jumps from / or /blog to /blog/123, if the JS Chunk of the blog/[id]/page component has not been loaded yet, the UI of the component exported in loading.tsx will be displayed first.

Redirect

You can use a Data Loader file to redirect a route. For example, if you have a routes/user/page.tsx file and want to redirect the corresponding route, you can create a routes/user/page.data.ts file:

routes/user/page.data.ts
import { redirect } from '@modern-js/runtime/router';

export const loader = () => {
  const user = await getUser();
  if (!user) {
    return redirect('/login');
  }
  return null;
};

If you want to redirect in a component,you can navigate by useNavigate hook, for example:

routes/user/page.ts
import { useNavigate } from '@modern-js/runtime/router';

export default () => {
  const navigate = useNavigate();
  navigate('/login');
};

ErrorBoundary

In each directory under routes/, developers can also define an error.tsx file that exports an <ErrorBoundary> component by default.

When this component exists in the routes directory, any rendering errors will be caught by the ErrorBoundary component. When the layout.tsx file is not defined in the directory, the <ErrorBoundary> component will not take effect.

<ErrorBoundary> can return the UI view when an error occurs. When the <ErrorBoundary> component is not declared in the current level, the error will bubble up to a higher-level component until it is caught or thrown. At the same time, when a component has an error, it will only affect the route component and its child components that catch the error. The status and view of other components are not affected and can continue to interact.

In the <ErrorBoundary> component, you can use useRouteError to get specific information about the error:

import { useRouteError } from '@modern-js/runtime/router';
export const ErrorBoundary = () => {
  const error = useRouteError();
  return (
    <div>
      <h1>{error.status}</h1>
      <h2>{error.message}</h2>
    </div>
  );
};

Runtime Configuration

In each root Layout component (routes/layout.ts), you can dynamically define runtime configuration:

src/routes/layout.tsx
// Define runtime config
import type { AppConfig } from '@modern-js/runtime';

export const config = (): AppConfig => {
  return {
    router: {
      createRoutes() {
        return [
          {
            path: 'modern',
            element: <div>modern</div>,
          },
        ];
      },
    },
  };
};

Hooks Before Rendering

In some scenarios, you may need to perform some operations before rendering the application. You can define an init hook in routes/layout.tsx. The init hook will be executed on both the client and server side. A basic example of usage is as follows:

src/routes/layout.tsx
import type { RuntimeContext } from '@modern-js/runtime';

export const init = (context: RuntimeContext) => {
  // do something
};

By using the init hook, you can mount some global data, and the runtimeContext variable can be accessed in other parts of the application:

NOTE

This feature is very useful when the application needs pre-rendered data, custom data injection, or framework migration (such as Next.js).

src/routes/layout.tsx
import { RuntimeContext } from '@modern-js/runtime';

export const init = (context: RuntimeContext) => {
  return {
    message: 'Hello World',
  };
};
src/routes/page.tsx
import { useRuntimeContext } from '@modern-js/runtime';

export default () => {
  const { context } = useRuntimeContext();
  const { message } = context.getInitData();

  return <div>{message}</div>;
};

When used with the SSR feature, the data returned by the init hook during SSR can be obtained on the client side. Developers can decide whether to re-fetch data on the client side to overwrite the SSR data. For example:

src/routes/layout.tsx
import { RuntimeContext } from '@modern-js/runtime';

export const init = (context: RuntimeContext) => {
  if (process.env.MODERN_TARGET === 'node') {
    return {
      message: 'Hello World By Server',
    };
  } else {
    const { context } = runtimeContext;
    const data = context.getInitData();
    // If do not get the expected data
    if (!data.message) {
      return {
        message: 'Hello World By Client',
      };
    }
  }
};

Preloading

In conventional routing, Modern.js automatically splits routes into chunks based on the route. When a user visits a specific route, the corresponding chunk will be loaded automatically, effectively reducing the loading time of the initial screen.

However, this also brings a problem: if the chunk corresponding to the route has not finished loading when the user visits the route, a white screen will appear.

In this case, you can define a Loading component to display a custom Loading component before the static assets are loaded.

To further improve the user experience and reduce loading time, Modern.js supports defining the prefetch attribute on the Link component to preload static assets and data.

<Link prefetch="intent" to="page">
INFO
  • This feature is currently only supported in Webpack projects and not yet supported in Rspack projects.
  • Preloading data currently only preloads the data returned by the Data Loader in SSR projects.

The prefetch attribute has three optional values:

  • none: default value, no prefetching, no additional behavior.
  • intent: This is the value we recommend for most scenarios. When you hover over the Link, the corresponding chunk and data defined in the data loader will be loaded automatically. When you move the mouse away, the loading will be cancelled automatically. In our tests, even fast clicks can reduce loading time by about 200ms.
  • render: The corresponding chunk and data defined in the Data Loader will be loaded when the Link component is rendered.

FAQ

  1. What is the difference between using render and not splitting static assets based on the route?
  • By using render, you can specify which routes to load during the initial screen, and you can control the rendering so that only when the Link component enters the visible area, the Link component will be rendered.

  • By using render, static assets will only be loaded when the system is idle, and will not compete with the static assets of the initial screen for network assets.

  • In the SSR scenario, data will also be pre-fetched.

Self-controlled Routing

With src/App.tsx as the convention for entry points, Modern.js will not perform any additional routing operations. Developers can use the API of React Router 6 for development, for example:

src/App.tsx
import { BrowserRouter, Route, Routes } from '@modern-js/runtime/router';

export default () => {
  return (
    <BrowserRouter>
      <Routes>
        <Route index element={<div>index</div>} />
        <Route path="about" element={<div>about</div>} />
      </Routes>
    </BrowserRouter>
  );
};
NOTE

Modern.js provides a series of optimizations for resource loading and rendering for conventional routing by default, and provides out-of-the-box SSR capabilities. When using self-controlled routing, developers need to encapsulate these capabilities themselves. It is recommended to use conventional routing.

Other

By default, Modern.js will enable the built-in routing scheme, which is React Router.

export default defineConfig({
  runtime: {
    router: true,
  },
});

As mentioned above, when the runtime.router configuration is enabled, Modern.js will export the API of React Router from the @modern-js/runtime/router namespace for developers to use, ensuring that developers and Modern.js are using the same code, and automatically wrapping the Provider component according to the router configuration. In addition, in this case, the code of React Router will be packed into the JS output.

If the project already has its own routing plan or does not need to use client-side routing, this feature can be disabled.

export default defineConfig({
  runtime: {
    router: false,
  },
});

As mentioned above, if the runtime.router configuration is disabled and react-router-dom is used directly for project routing management, the Provider needs to be wrapped according to the React Router documentation.