A floating blue and orange blueprint of a layered building with spinning gears, watched by a relaxed red Maine Coon cat on a stack of books.

Modern.js: ByteDance's Progressive React Framework with a Rust-Powered Heart

The Orange Cat
The Orange Cat

If you have ever started a React project and immediately drowned in decisions, you know the feeling. React itself gives you a component model and nothing else. Before you write a single feature, you have to pick a router, settle on a data-fetching strategy, choose between client and server rendering, configure a bundler, and glue on some kind of backend. Modern.js is ByteDance's answer to that fatigue: a progressive, full-stack React framework that bundles all of those concerns into one cohesive, convention-driven package.

What makes it stand out from the Next.js and Remix crowd is its engine. Modern.js is built on Rsbuild, which is built on Rspack, ByteDance's Rust-based, webpack-compatible bundler. That means you get near-instant builds and hot module replacement without ever touching a webpack config. It reportedly powers thousands of web applications inside ByteDance, and the word "progressive" in its pitch is load-bearing: you can start with a humble client-rendered SPA and opt into SSR, an integrated backend, or module federation later, one config flag at a time.

A quick note on packaging before we dive in. The package you install to get started is @modern-js/app-tools, but that is the build tool and CLI layer rather than a single import. The framework is really a family: @modern-js/app-tools for the build, @modern-js/runtime for the React-side APIs, the Modern.js server packages, and a plugin ecosystem. You scaffold everything with @modern-js/create, and the framework keeps all those packages version-locked together.

Why It Earns a Spot in Your Toolbox

Modern.js leans hard into being batteries-included without being a black box. The headline capabilities are worth calling out individually:

  • File-system routing out of the box, using layout.tsx and page.tsx conventions, dynamic segments, and catch-all routes inside src/routes.
  • Multiple rendering modes that you toggle with config: client-side rendering, server-side rendering, static site generation, and streaming SSR (which becomes the default once SSR is enabled).
  • Data Loaders that run before a route renders and, crucially, execute in parallel across nested routes to kill the classic request-then-render waterfall.
  • An integrated BFF where you write server functions in api/ and call them from the frontend as if they were ordinary async functions, with types preserved across the boundary.
  • Conventional configuration through a single modern.config.ts file.
  • A plugin architecture for extending both build-time and runtime behavior, plus first-class Module Federation support.

The throughline is that you adopt complexity only when you need it. None of these features demand buy-in up front.

Getting It Onto Your Machine

You do not install Modern.js the way you install a typical library. Instead, you scaffold a project with the @modern-js/create generator, which sets up the locked set of @modern-js/* packages for you.

# npm
npx @modern-js/create@latest myapp

# or scaffold into an existing empty directory
mkdir myapp && cd myapp && npx @modern-js/create@latest

If you prefer to drive the generator through Yarn, the equivalent is:

yarn create @modern-js/app myapp

The generator walks you through a few prompts and then produces a tidy project skeleton:

.
├── biome.json
├── modern.config.ts
├── package.json
├── src
│   ├── modern-app-env.d.ts
│   ├── modern.runtime.ts
│   └── routes
│       ├── index.css
│       ├── layout.tsx
│       └── page.tsx
└── tsconfig.json

From there, modern dev starts the development server and modern build produces a production bundle. Both commands come from @modern-js/app-tools.

Wiring Up the Framework

Everything the framework does is steered from a single modern.config.ts at the project root. The shape is deliberately small: you call defineConfig and register the appTools() plugin.

import { appTools, defineConfig } from '@modern-js/app-tools';

export default defineConfig({
  server: {
    ssr: true, // enables SSR; streaming is the default SSR mode
  },
  plugins: [appTools()],
});

The appTools() plugin is what wires modern dev and modern build to Rsbuild and the Modern.js server. The server.ssr flag is your rendering switch. Flip it to true and you get streaming SSR by default. If you need the old-school buffered, non-streaming behavior, you can set server.ssr.mode: 'string', or be explicit about streaming with server.ssr.mode: 'stream'. Because rendering is a config concern rather than an architectural one, moving a project from a CSR SPA to streaming SSR is genuinely a one-line change.

Routing by Convention

Modern.js maps your folder structure directly to URLs. Directory names under src/routes/ become URL segments, and two filename conventions do most of the work. A page.tsx is the content component for a route; its very presence is what makes that URL reachable. A layout.tsx wraps that directory and all of its sub-routes, rendering its children through an <Outlet />. Wrap a directory in brackets like [id] for a dynamic parameter, and use $ for a catch-all wildcard.

A layout is just a component that renders an outlet:

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

export default function Layout() {
  return (
    <div>
      <nav>My App</nav>
      <Outlet />
    </div>
  );
}

And a page is about as plain as React gets:

// src/routes/page.tsx
export default function Page() {
  return <h1>Home</h1>;
}

If you have worked with Remix or React Router in framework mode, this will feel immediately familiar, because Modern.js builds its routing, streaming, and loaders on React Router semantics. The mental model transfers cleanly from that world, though it is a different shape from Next's App Router.

Fetching Data Without Waterfalls

Here is where the framework starts paying real dividends. Any route component, whether it is a layout, a page, or a catch-all $, can have a sibling *.data file that exports a loader. The loader runs before the component renders. In a CSR app it runs in the browser; under SSR it runs on the server during the initial load and during navigation. Because of that, any Node APIs or server-only dependencies you use inside a loader are automatically stripped out of the client bundle.

A loader is just an async function that returns data:

// src/routes/user/page.data.ts
export const loader = async () => {
  const res = await fetch('https://api.example.com/users');
  return res.json();
};

The matching component reads that data with the useLoaderData hook:

// src/routes/user/page.tsx
import { useLoaderData } from '@modern-js/runtime/router';

interface User {
  id: number;
  name: string;
}

export default function UserPage() {
  const users = useLoaderData() as User[];
  return (
    <ul>
      {users.map((u) => (
        <li key={u.id}>{u.name}</li>
      ))}
    </ul>
  );
}

The detail that matters most is timing. When a visitor lands on /user/profile, the loaders for both /user and /user/profile fire in parallel rather than waiting for each parent to resolve before its child begins. That is the antidote to the request-render waterfall that plagues naive nested data fetching, and you get it for free just by colocating .data files with your routes.

A Backend That Feels Like Local Functions

The integrated backend-for-frontend is Modern.js's most distinctive trick. You write server functions in api/lambda, exporting functions named after HTTP methods, and then import them directly into your frontend code. The framework rewrites that import into an actual HTTP request, while keeping types shared across the network boundary.

The server side is refreshingly terse:

// api/lambda/hello.ts
export const get = async () => 'Hello Modern.js';

On the client, you import it as if it lived next door:

// src/routes/page.tsx
import { useState, useEffect } from 'react';
import { get as hello } from '@api/hello';

export default function Page() {
  const [text, setText] = useState('');

  useEffect(() => {
    hello().then(setText); // actually calls GET /api/hello
  }, []);

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

File paths map to routes in the way you would hope. The default prefix is /api, which you can change through bff.prefix:

File Path Route
api/lambda/hello.ts /api/hello
api/lambda/user/list.ts /api/user/list
api/lambda/[id]/info.ts /api/:id/info

Dynamic parameters arrive as plain function arguments, while query strings and request bodies come through a typed RequestOption:

// api/lambda/user/[id].ts
export const get = async (id: number) => ({ userId: id });
// client: getUser(123) -> GET /api/user/123
// api/lambda/search.ts
import type { RequestOption } from '@modern-js/plugin-bff/server';

export const post = async ({
  query,
  data,
}: RequestOption<Record<string, string>, Record<string, string>>) => {
  // query and data are fully typed end to end
  return { results: [] };
};
// client: post({ query: { sort: 'asc' }, data: { keyword: 'test' } })

One rule trips people up, so commit it to memory: src/ and api/ cannot import each other directly. When you need to share types or validators across the boundary, put them in a root-level shared/ directory that both sides can import. The lightweight style shown above is called "function mode"; for heavier needs there is also a "framework mode" that runs your BFF on a full Express- or Koa-style server, plus cross-project invocation through an SDK.

Knowing When to Reach for It

Modern.js is a sharp tool, but it is worth being honest about its shape. Its ecosystem is small compared with Next.js, which has well over a hundred thousand stars to Modern.js's few thousand, so you will find fewer third-party plugins, fewer tutorials, and fewer Stack Overflow answers in a pinch. The English documentation is good but partly translation-driven from the project's Chinese origins, so a deep-linked doc page occasionally lags or moves. And remember that "installing Modern.js" means keeping a whole family of @modern-js/* packages locked to the same version, with @rsbuild/core pinned to an exact release underneath.

None of that disqualifies it. Reach for Modern.js when you want a full-stack React framework with Rspack-class build and HMR speed but have no appetite for hand-rolling webpack. Reach for it when the integrated, type-safe BFF appeals to you and you would rather call api/lambda functions than write fetch glue. Reach for it if you already love Remix-style parallel loaders but want SSR, SSG, streaming, and a backend bundled together, or if your team is standardizing on ByteDance's Rspack and Rsbuild toolchain and wants one consistent build story across a large monorepo. If, on the other hand, you need the biggest possible ecosystem, mature React Server Components, or turnkey Vercel-style hosting, Next.js remains the safer default.

For the right project, though, Modern.js delivers something genuinely pleasant: a framework that starts small, grows with you one config flag at a time, and runs on a Rust engine fast enough that you stop thinking about your build at all. That is a framework happy to get out of your way, which is exactly what you want when there is real work to do.