Routing

Modern.js routing is based on React Router 6, offering file convention-based routing capabilities and supporting the industry-popular nested routing pattern. When an entry is recognized as conventional routing, Modern.js automatically generates the corresponding routing structure based on the file system.

NOTE

The routing mentioned in this section all refers to conventional routing.

What is Nested Routing

Nested routing is a pattern that couples URL segments with the component hierarchy and data. Typically, URL segments determine:

  • The layouts to render on the page
  • The data dependencies of those layouts

Therefore, when using nested routing, the page's routing and UI structure are in correspondence. We will introduce this routing pattern in detail.

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

Routing File Conventions

In the routes/ directory, subdirectory names are mapped to route URLs. Modern.js has two file conventions: layout.tsx and page.tsx. These files determine the layout hierarchy of the application:

  • page.tsx: This is the content component. When this file exists in a directory, the corresponding route URL is accessible.
  • layout.tsx: This is the layout component and controls the layout of all sub-routes in its directory by using <Outlet> to represent child components.
TIP

.ts, .js, .jsx, or .tsx file extensions can be used for the above convention files.

Page

The <Page> component refers to all page.tsx files in the routes/ directory and is the leaf component for all routes. All routes should end with a <Page> component except for wildcard routes.

routes/page.tsx
export default () => {
  return <div>Hello world</div>
};

When the application has the following directory structure:

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

The following two routes will be produced:

  • /
  • /user

Layout

The <Layout> component refers to all layout.tsx files in the routes/ directory. These represent the layout of their respective route segments, using <Outlet> for child components.

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

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

<Outlet> is an API provided by React Router 6. For more details, see Outlet.

Under different directory structures, the components represented by <Outlet> are also different. To illustrate the relationship between <Layout> and <Outlet>, let's consider the following directory structure:

.
└── 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 from routes/page.tsx. The UI structure of the route is:
<Layout>
  <Page />
</Layout>
  1. When the route is /blog, the <Outlet> in routes/layout.tsx represents the component exported from routes/blog/page.tsx. The UI structure of the route is:
<Layout>
  <BlogPage />
</Layout>
  1. When the route is /user, the <Outlet> in routes/layout.tsx represents the component exported from routes/user/layout.tsx. The <Outlet> in routes/user/layout.tsx represents the component exported from routes/user/page.tsx. The UI structure of the route is:
<Layout>
  <UserLayout>
    <UserPage />
  </UserLayout>
</Layout>

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

Dynamic Routes

Files and directories named with [] are turned into dynamic routes. For instance, consider the following directory structure:

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

The routes/[id]/page.tsx file will be converted to the /:id route. Apart from the /blog route that can be exactly matched, all /xxx paths will match this route.

In the component, you can use useParams to get parameters named accordingly.

import { useParams } from '@modern-js/runtime/router';

function Blog() {
  const { id } = useParams();
  return <div>current blog ID is: {id}</div>;
}
export default Blog;

Optional Dynamic Routes

Files and directories named with [$] are turned into optional dynamic routes. For example, the following directory structure:

.
└── routes
    ├── blog
    │   └── [id$]
    │       └── 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 can be used to distinguish between create and edit actions.

import { useParams } from '@modern-js/runtime/router';

function Blog() {
  const { id } = useParams();
  if (id) {
    return <div>current blog ID is: {id}</div>;
  }

  return <div>create new blog</div>;
}
export default Blog;

Wildcard Routes

If there is a $.tsx file in a subdirectory, it acts as a wildcard route component and will be rendered when no other routes match.

NOTE

$.tsx can be thought of as a special <Page> component. If no routes match, $.tsx will be rendered as a child component of the <Layout>.

WARNING

If there is no <Layout> component in the current directory, $.tsx will not have any effect.

For example, consider the following directory structure:

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

When you visit /blog/a and no routes match, the page will render the routes/blog/$.tsx component. The UI structure of the route is:

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

If you want /blog to match the blog/$.tsx file as well, you need to remove the blog.tsx file from the same directory and ensure there are no other sub-routes under blog.

Similarly, you can use useParams to capture the remaining part of the URL in the $.tsx component.

$.tsx
import { useParams } from '@modern-js/runtime/router';

function Blog() {
  // When the path is `/blog/aaa/bbb`
  const params = useParams();
  console.log(params) // ---> { '*': 'aaa/bbb' }

  return <div>current blog URL is {params["*"]}</div>;
}
export default Blog;

Custom 404 Page

Wildcard routes can be added to any subdirectory in the routes/ directory. A common use case is to customize a 404 page at any level using a $.tsx file.

For instance, if you want to show a 404 page for all unmatched routes, you can add a routes/$.tsx file:

.
└── routes
    ├── $.tsx
    ├── blog
    │   └── [id$]
    │       └── page.tsx
    ├── layout.tsx
    └── page.tsx
function Page404() {
  return <div>404 Not Found</div>;
}
export default Page404;

At this point, when accessing routes other than / or /blog/*, they will match the routes/$.tsx component and display a 404 page.

Route Handle Configuration

In some scenarios, each route might have its own data which the application needs to access in other components. A common example is retrieving breadcrumb information for the matched route.

Modern.js provides a convention where each Layout, $, or Page file can define its own config file such as page.config.ts. In this file, we conventionally export a named export handle, in which you can define any properties:

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

These defined properties can be accessed using the useMatches hook.

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

Pathless Layouts

When a directory name starts with __, the corresponding directory name will not be converted into an actual route path, for example:

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

Modern.js will generate /login and /sign routes, and the __auth/layout.tsx component will serve as the layout for login/page.tsx and sign/page.tsx, but __auth will not appear as a path segment in the URL.

This feature is useful when you need to create independent layouts or categorize routes without adding additional path segments.

Pathless File Segments

In some cases, a project may need complex routes that do not have independent UI layouts. Creating these routes as regular directories can lead to deeply nested directories.

Modern.js supports replacing directory names with . to divide route segments. For example, to create a route like /user/profile/2022/edit, you can create the following file:

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

When accessed, the resulting route will have the following UI structure:

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

Route Redirections

In some applications, you may need to redirect to another route based on user identity or other data conditions. In Modern.js, you can use a Data Loader file to fetch data or use traditional React components with useEffect.

Redirecting in Data Loader

Create a page.data.ts file in the same directory as page.tsx. This file is the Data Loader for that route. In the Data Loader, you can call the redirect API to perform route redirections.

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;
};

Redirecting in a Component

To perform a redirection within a component, use the useNavigate hook as shown below:

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

export default () => {
  const navigate = useNavigate();
  useEffect(() => {
    getUser().then(user => {
      if (!user) {
        navigate('/login');
      }
    });
  });

  return <div>Hello World</div>;
};

Error Handling

In each directory under routes/, developers can define an error.tsx file that exports an <ErrorBoundary> component. When this component is present, rendering errors in the route directory will be caught by the ErrorBoundary component.

<ErrorBoundary> can return the UI view when an error occurs. If the current level does not declare an <ErrorBoundary> component, errors will bubble up to higher-level components until they are caught or thrown. Additionally, when an error occurs within a component, it only affects the route component and its children, leaving the state and view of other components unaffected and interactive.

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

import { useRouteError } from '@modern-js/runtime/router';

const ErrorBoundary = () => {
  const error = useRouteError();
  return (
    <div>
      <h1>{error.status}</h1>
      <h2>{error.message}</h2>
    </div>
  );
};
export default ErrorBoundary;

Loading (Experimental)

Experimental

This feature is currently experimental, and its API may change in the future.

In conventional routing, Modern.js automatically splits routes into chunks (each route loads as a separate JS chunk). When users visit a specific route, the corresponding chunk is automatically loaded, effectively reducing the first-screen load time. However, this can lead to a white screen if the route's chunk is not yet loaded.

Modern.js supports solving this issue with a loading.tsx file. Each directory under routes/ can create a loading.tsx file that exports a <Loading> component.

WARNING

If there is no <Layout> component in the current directory, loading.tsx will not have any effect. To ensure a good user experience, Modern.js recommends adding a root Loading component to each application.

When both this component and a layout component exist in the route directory, all child routes under this level will first display the UI from the exported <Loading> component until the corresponding JS chunk is fully loaded. For example, with the following file structure:

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

When defining a loading.tsx, if the route transitions from / to /blog or from /blog to /blog/123, and the JS chunk for the route is not yet loaded, the UI from the <Loading> component will be displayed first. This results in the following UI structure:

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>

Prefetching

Most white screens during route transitions can be optimized by defining a <Loading> component. Modern.js also supports preloading static resources and data with the prefetch attribute on <Link> components.

For applications with higher performance requirements, prefetching can further enhance the user experience by reducing the time spent displaying the <Loading> component:

<Link prefetch="intent" to="page">
TIP

Data preloading currently only preloads data returned by the Data Loader in SSR projects.

The prefetch attribute has three optional values:

  • none: The default value. No prefetching, no additional behavior.
  • intent: This is the recommended value for most scenarios. When you hover over the Link, it will automatically start loading the corresponding chunk and the data defined in the Data Loader. If the mouse moves away, the loading is automatically canceled. In our tests, even quick clicks can reduce load time by approximately 200ms.
  • render: When the <Link> component is rendered, it begins loading the corresponding chunk and data defined in the Data Loader.
Difference Between "render" and Not Using Route Splitting
  • render allows you to control the timing of route splitting, triggering only when the <Link> component enters the viewport. You can control the loading timing of the split by adjusting the rendering position of the <Link> component.
  • render loads static resources only during idle times, thus not occupying the loading time of critical modules.
  • Besides preloading route splits, render will also initiate data prefetching in SSR projects.

Runtime Configuration

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

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

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

Pre-render Hooks

In some scenarios, you need to perform certain actions before the application renders. You can define the init hook in routes/layout.tsx, which will be executed both on the client and server. A basic example is shown below:

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

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

With the init hook, you can mount some global data that can be accessed elsewhere in the application via the runtimeContext variable:

NOTE

This feature is very useful when applications require pre-rendered data, custom data injection, or framework migration (e.g., 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.initialData;

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

With SSR, the browser can obtain the data returned by init during SSR. Developers can decide whether to re-fetch the data on the browser side to override 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 the expected data is not obtained
    if (!data.message) {
      return {
        message: 'Hello World By Client',
      };
    }
  }
};

FAQ

  1. Why there is @modern-js/runtime/router to re-export React Router API

Notice that all the code examples in the documentation use API exported from the @modern-js/runtime/router package instead of directly using the API exported from the React Router package. So, what is the difference?

The API exported from @modern-js/runtime/router is entirely consistent with the API from the React Router package. If you encounter issues while using an API, check the React Router documentation and issues first.

Additionally, when using conventional routing, make sure to use the API from @modern-js/runtime/router instead of directly using the React Router API. Modern.js internally installs React Router, and using the React Router API directly in your application may result in two versions of React Router being present, causing unexpected behavior.

NOTE

If you must directly use the React Router package's API (e.g., route behavior wrapped in a unified npm package), you can set source.alias to point react-router and react-router-dom to the project's dependencies, avoiding the issue of two versions of React Router.