React Integration

This guide covers integrating @esmx/router with React. Unlike Vue, React does not need a separate integration package — the router's built-in micro-app system handles mounting, unmounting, and server-side rendering directly.

Installation

Install only the core router package:

npm install @esmx/router

No additional integration package is needed. React works through the router's apps callback, which provides mount, unmount, and renderToString lifecycle hooks.

Key Concepts

The React integration uses the micro-app pattern:

  1. apps callback — Tells the router how to mount, unmount, and render your React app
  2. mount(el) — Creates a React root and renders the app into a DOM element
  3. unmount() — Unmounts the React root for cleanup
  4. renderToString() — SSRs the app to an HTML string

The router passes itself to the apps callback, so your React components can access it via props or React context.

Step-by-Step Setup

1. Define Your Routes

Routes are framework-agnostic — the same as Vue:

src/routes.ts
import type { RouteConfig } from '@esmx/router';

export const routes: RouteConfig[] = [
  {
    path: '/',
    component: () => import('./layouts/MainLayout'),
    children: [
      { path: '', component: () => import('./pages/Home') },
      { path: 'about', component: () => import('./pages/About') },
      {
        path: 'users/:id',
        component: () => import('./pages/UserProfile'),
        meta: { requiresAuth: true }
      }
    ]
  }
];

2. Create the Router Context

Set up a React context so any component can access the router:

src/router-context.tsx
import { createContext, useContext, useState, useEffect } from 'react';
import type { Router, Route } from '@esmx/router';

interface RouterContextValue {
  router: Router;
  route: Route;
}

const RouterContext = createContext<RouterContextValue | null>(null);

export function RouterProvider({
  router,
  children
}: {
  router: Router;
  children: React.ReactNode;
}) {
  const [route, setRoute] = useState(router.route);

  useEffect(() => {
    return router.afterEach((to) => {
      setRoute(to);
    });
  }, [router]);

  return (
    <RouterContext.Provider value={{ router, route }}>
      {children}
    </RouterContext.Provider>
  );
}

export function useRouter(): Router {
  const context = useContext(RouterContext);
  if (!context) {
    throw new Error('useRouter must be used within a RouterProvider');
  }
  return context.router;
}

export function useRoute(): Route {
  const context = useContext(RouterContext);
  if (!context) {
    throw new Error('useRoute must be used within a RouterProvider');
  }
  return context.route;
}

3. Create the Root Component

Build the app root that renders matched route components:

src/App.tsx
import { RouterProvider, useRoute } from './router-context';
import type { Router } from '@esmx/router';

function AppContent() {
  const route = useRoute();

  const Component = route.matched[0]?.component;
  return Component ? <Component /> : <div>Not Found</div>;
}

export default function App({ router }: { router: Router }) {
  return (
    <RouterProvider router={router}>
      <AppContent />
    </RouterProvider>
  );
}

Use router.resolveLink() to build a navigation link component:

src/components/RouterLink.tsx
import type { ReactNode, MouseEvent } from 'react';
import { useRouter } from '../router-context';

interface RouterLinkProps {
  to: string;
  activeClass?: string;
  className?: string;
  children: ReactNode;
}

export function RouterLink({ to, activeClass, className, children }: RouterLinkProps) {
  const router = useRouter();
  const link = router.resolveLink({ to, activeClass });

  function handleClick(e: MouseEvent) {
    if (e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) return;
    e.preventDefault();
    router.push(to);
  }

  return (
    <a
      href={link.attributes.href}
      className={[className, link.attributes.class].filter(Boolean).join(' ')}
      onClick={handleClick}
    >
      {children}
    </a>
  );
}

5. Client Entry

The client entry creates the router with the apps callback and uses createRoot for mounting:

src/entry.client.tsx
import { Router, RouterMode } from '@esmx/router';
import { createRoot } from 'react-dom/client';
import { createElement } from 'react';
import App from './App';
import { routes } from './routes';

const router = new Router({
  appId: 'app',
  mode: RouterMode.history,
  routes,
  apps: (router) => {
    let root = null;
    return {
      mount(el) {
        root = createRoot(el);
        root.render(createElement(App, { router }));
      },
      unmount() {
        root?.unmount();
        root = null;
      },
      async renderToString() {
        const { renderToString } = await import('react-dom/server');
        return renderToString(createElement(App, { router }));
      }
    };
  }
});

The apps callback:

  • mount(el) — Called when the router needs to render the app. Creates a React root and renders the app internally.
  • unmount() — Called when the app should be torn down. Unmounts the React root for cleanup.
  • renderToString() — Called on the server for SSR. Returns the HTML string.

6. Server Entry (SSR)

src/entry.server.tsx
import type { RenderContext } from '@esmx/core';
import { Router, RouterMode } from '@esmx/router';
import { createElement } from 'react';
import { renderToString } from 'react-dom/server';
import App from './App';
import { routes } from './routes';

export default async (rc: RenderContext) => {
  const router = new Router({
    mode: RouterMode.memory,
    base: new URL(rc.params.url, 'http://localhost'),
    routes,
    apps: (router) => ({
      mount(el) { /* client only */ },
      unmount() { /* client only */ },
      async renderToString() {
        return renderToString(createElement(App, { router }));
      }
    })
  });

  await router.replace(rc.params.url);

  const html = await router.renderToString();

  rc.html = `<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    ${rc.preload()}
    ${rc.css()}
</head>
<body>
    <div id="app">${html}</div>
    ${rc.importmap()}
    ${rc.moduleEntry()}
    ${rc.modulePreload()}
</body>
</html>`;
};

7. Node Entry

Use createRspackReactApp from @esmx/rspack-react for React-specific build tooling:

src/entry.node.ts
import http from 'node:http';
import type { EsmxOptions } from '@esmx/core';

export default {
  async devApp(esmx) {
    return import('@esmx/rspack-react').then((m) =>
      m.createRspackReactApp(esmx)
    );
  },

  async server(esmx) {
    const server = http.createServer((req, res) => {
      esmx.middleware(req, res, async () => {
        const rc = await esmx.render({
          params: { url: req.url }
        });
        res.end(rc.html);
      });
    });

    server.listen(3000, () => {
      console.log('Server started: http://localhost:3000');
    });
  }
} satisfies EsmxOptions;

createRspackReactApp configures Rspack with JSX/TSX support, React Refresh for HMR, and proper SSR bundling.

Using the Router in Components

Use the useRouter hook to navigate programmatically:

src/pages/UserProfile.tsx
import { useRouter, useRoute } from '../router-context';

export default function UserProfile() {
  const router = useRouter();
  const route = useRoute();

  const userId = route.params.id;

  return (
    <div>
      <h1>User {userId}</h1>
      <p>Current path: {route.path}</p>
      <p>Query: {JSON.stringify(route.query)}</p>

      <button onClick={() => router.push('/')}>
        Go Home
      </button>
      <button onClick={() => router.replace('/about')}>
        Replace with About
      </button>
      <button onClick={() => router.back()}>
        Go Back
      </button>
    </div>
  );
}
src/layouts/MainLayout.tsx
import { RouterLink } from '../components/RouterLink';
import { useRoute } from '../router-context';

export default function MainLayout() {
  const route = useRoute();

  // Render children from matched routes
  const ChildComponent = route.matched[1]?.component;

  return (
    <div>
      <nav>
        <RouterLink to="/">Home</RouterLink>
        <RouterLink to="/about">About</RouterLink>
        <RouterLink to="/users/42" activeClass="nav-active">
          User 42
        </RouterLink>
      </nav>

      <main>
        {ChildComponent ? <ChildComponent /> : null}
      </main>
    </div>
  );
}

Accessing Route Information

import { useRoute } from '../router-context';

function MyComponent() {
  const route = useRoute();

  // Current path
  route.path        // '/users/42'

  // Route parameters
  route.params       // { id: '42' }

  // Query string parameters
  route.query        // { tab: 'profile' }

  // Route meta data
  route.meta         // { requiresAuth: true }

  // Matched route configs (parent → child)
  route.matched      // RouteConfig[]
}

Project File Structure

A typical React + SSR project with @esmx/router:

src/
├── entry.node.ts         # Node.js server setup, dev/build config
├── entry.server.tsx      # SSR rendering logic
├── entry.client.tsx      # Client-side mounting
├── router-context.tsx    # React context for router (useRouter, useRoute)
├── routes.ts             # Route definitions
├── App.tsx               # Root component
├── components/
│   └── RouterLink.tsx    # Navigation link component
├── layouts/
│   └── MainLayout.tsx    # Layout with navigation
└── pages/
    ├── Home.tsx
    ├── About.tsx
    └── UserProfile.tsx

What's Next?