DEV Community

Cover image for Building Micro-Frontends with React Router v7 RSC and Module Federation
GT Hironobu
GT Hironobu

Posted on

Building Micro-Frontends with React Router v7 RSC and Module Federation

Introduction

React Router v7 introduces experimental support for React Server Components (RSC). In this comprehensive guide, we'll explore how to implement a micro-frontend architecture using RSC mode with Module Federation, creating a scalable user management system.

What You'll Learn

  • React Router v7 RSC mode fundamentals
  • Module Federation configuration and setup
  • Server Components vs Client Components patterns
  • Dynamic remote component loading
  • Building a production-ready user management system

Tech Stack

  • React Router v7.9.2 (RSC mode)
  • Vite 7.1.6
  • @originjs/vite-plugin-federation
  • TypeScript
  • Tailwind CSS
  • Drizzle ORM + SQLite

Project Architecture

workspace/
├── atomic-shared/          # Remote component library
│   ├── src/
│   │   └── components/
│   │       ├── atoms/      # Button, Input, Label
│   │       ├── molecules/  # FormField, ConfirmDialog
│   │       └── organisms/  # UserForm, UserCard
│   └── vite.config.ts
└── react-router-rsc-app/   # Host application
    ├── app/
    │   ├── routes/         # Route definitions
    │   ├── components/     # Client Components
    │   ├── services/       # Business logic
    │   └── utils/          # Utilities
    └── vite.config.ts
Enter fullscreen mode Exit fullscreen mode

Part 1: Setting Up the Remote Application

1.1 Vite Configuration for Module Federation

We'll create a component library following Atomic Design principles and expose it via Module Federation.

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import federation from '@originjs/vite-plugin-federation'

export default defineConfig({
  plugins: [
    react(),
    federation({
      name: 'atomicShared',
      filename: 'remoteEntry.js',
      exposes: {
        './Button': './src/components/atoms/Button',
        './Input': './src/components/atoms/Input',
        './Label': './src/components/atoms/Label',
        './FormField': './src/components/molecules/FormField',
        './ConfirmDialog': './src/components/molecules/ConfirmDialog',
        './UserForm': './src/components/organisms/UserForm',
        './UserCard': './src/components/organisms/UserCard',
      },
      shared: ['react', 'react-dom'],
    }),
  ],
  build: {
    modulePreload: false,
    target: 'esnext',
    minify: false,
    cssCodeSplit: false,
  },
  server: {
    port: 5001,
    cors: true,
  },
  preview: {
    port: 5001,
    cors: true,
  },
})
Enter fullscreen mode Exit fullscreen mode

1.2 Building Atomic Components

Let's create reusable components following the Atomic Design pattern.

Atom: Button Component

import React from 'react';

export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'secondary' | 'danger';
  children: React.ReactNode;
}

export function Button({ 
  variant = 'primary', 
  children, 
  className = '',
  ...props 
}: ButtonProps) {
  const baseStyles = 'px-4 py-2 rounded font-medium transition-colors';
  const variantStyles = {
    primary: 'bg-blue-600 text-white hover:bg-blue-700',
    secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300',
    danger: 'bg-red-600 text-white hover:bg-red-700',
  };

  return (
    <button
      className={`${baseStyles} ${variantStyles[variant]} ${className}`}
      {...props}
    >
      {children}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Atom: Input Component

import React from 'react';

export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}

export function Input({ className = '', ...props }: InputProps) {
  return (
    <input
      className={`w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${className}`}
      {...props}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Molecule: FormField Component

import React from 'react';
import { Input, InputProps } from '../atoms/Input';
import { Label } from '../atoms/Label';

export interface FormFieldProps extends Omit<InputProps, 'id'> {
  label: string;
  name: string;
  error?: string;
}

export function FormField({ 
  label, 
  name, 
  error, 
  ...inputProps 
}: FormFieldProps) {
  return (
    <div className="space-y-1">
      <Label htmlFor={name}>{label}</Label>
      <Input
        id={name}
        name={name}
        {...inputProps}
      />
      {error && (
        <p className="text-sm text-red-600">{error}</p>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Part 2: Configuring the Host Application

2.1 Enabling RSC Mode in Vite

Configure React Router v7 with RSC support.

import { unstable_reactRouterRSC as reactRouterRSC } from "@react-router/dev/vite";
import tailwindcss from "@tailwindcss/vite";
import rsc from "@vitejs/plugin-rsc";
import { defineConfig } from "vite";
import devtoolsJson from "vite-plugin-devtools-json";
import tsconfigPaths from "vite-tsconfig-paths";

export default defineConfig({
  plugins: [
    tailwindcss(),
    tsconfigPaths(),
    reactRouterRSC(),  // Enable RSC mode
    rsc(),             // Vite RSC plugin
    devtoolsJson(),
  ],
});
Enter fullscreen mode Exit fullscreen mode

2.2 TypeScript Configuration

Add type definitions for remote modules.

declare module "atomicShared/Button" {
  export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
    variant?: 'primary' | 'secondary' | 'danger';
    children: React.ReactNode;
  }
  export function Button(props: ButtonProps): JSX.Element;
}

declare module "atomicShared/Input" {
  export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
  export function Input(props: InputProps): JSX.Element;
}

declare module "atomicShared/Label" {
  export interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {
    children: React.ReactNode;
  }
  export function Label(props: LabelProps): JSX.Element;
}

declare module "atomicShared/FormField" {
  import { InputProps } from "atomicShared/Input";

  export interface FormFieldProps extends Omit<InputProps, 'id'> {
    label: string;
    name: string;
    error?: string;
  }
  export function FormField(props: FormFieldProps): JSX.Element;
}
Enter fullscreen mode Exit fullscreen mode

2.3 Remote Module Loader

In RSC mode, remote modules can only be loaded on the client side.

import * as React from "react";
import * as ReactDOM from "react-dom";

type RemoteModule = {
  get: (module: string) => Promise<() => any>;
  init: (shared: any) => void;
};

const remoteUrl = "http://localhost:5001/assets/remoteEntry.js";
let remoteModule: RemoteModule | null = null;
let remoteInitialized = false;

async function loadRemoteEntry(): Promise<RemoteModule> {
  if (typeof window === "undefined") {
    throw new Error("Remote modules can only be loaded on the client side");
  }

  if (remoteModule) {
    return remoteModule;
  }

  console.log(`[MF] Loading remote entry from: ${remoteUrl}`);

  // Import remote entry as ES module
  const remote = await import(/* @vite-ignore */ remoteUrl);

  remoteModule = remote as RemoteModule;
  return remoteModule;
}

async function initRemote(): Promise<void> {
  if (remoteInitialized) {
    return;
  }

  const remote = await loadRemoteEntry();

  // Initialize with shared dependencies
  remote.init({
    react: {
      "19.2.0": {
        get: () => Promise.resolve(() => React),
        loaded: true,
        from: "host",
      },
    },
    "react-dom": {
      "19.2.0": {
        get: () => Promise.resolve(() => ReactDOM),
        loaded: true,
        from: "host",
      },
    },
  });

  remoteInitialized = true;
  console.log("[MF] Remote initialized");
}

export async function loadRemoteModule<T = any>(moduleName: string): Promise<T> {
  // Client-side only
  if (typeof window === "undefined") {
    throw new Error("Remote modules can only be loaded on the client side");
  }

  try {
    await initRemote();

    if (!remoteModule) {
      throw new Error("Remote not initialized");
    }

    const factory = await remoteModule.get(moduleName);
    const module = factory();
    return module;
  } catch (error) {
    console.error(`[MF] Failed to load module ${moduleName}:`, error);
    throw error;
  }
}
Enter fullscreen mode Exit fullscreen mode

2.4 Remote Component Wrappers

Wrap remote components as Client Components.

"use client";

import { lazy } from "react";
import { loadRemoteModule } from "~/utils/loadRemoteModule";

/**
 * Remote Components from atomic-shared via Module Federation
 * 
 * These components require "use client" directive because:
 * - They are dynamically loaded from a remote source
 * - They use React hooks and interactive features
 * - Module Federation operates on the client side
 */

export const RemoteButton = lazy(() =>
  loadRemoteModule("./Button").then((m) => ({ default: m.Button }))
);

export const RemoteInput = lazy(() =>
  loadRemoteModule("./Input").then((m) => ({ default: m.Input }))
);

export const RemoteLabel = lazy(() =>
  loadRemoteModule("./Label").then((m) => ({ default: m.Label }))
);

export const RemoteFormField = lazy(() =>
  loadRemoteModule("./FormField").then((m) => ({ default: m.FormField }))
);

export const RemoteUserForm = lazy(() =>
  loadRemoteModule("./UserForm").then((m) => ({ default: m.UserForm }))
);

export const RemoteUserCard = lazy(() =>
  loadRemoteModule("./UserCard").then((m) => ({ default: m.UserCard }))
);

export const RemoteConfirmDialog = lazy(() =>
  loadRemoteModule("./ConfirmDialog").then((m) => ({ default: m.ConfirmDialog }))
);

// Re-export types
export type { ButtonProps } from "atomicShared/Button";
export type { InputProps } from "atomicShared/Input";
export type { LabelProps } from "atomicShared/Label";
export type { FormFieldProps } from "atomicShared/FormField";
Enter fullscreen mode Exit fullscreen mode

Part 3: Implementing Routes with RSC

3.1 User Registration Page (Server Component)

import { redirect } from "react-router";
import type { Route } from "./+types/register";
import { userService } from "~/services/user.service";
import { RegisterForm } from "~/components/RegisterForm";

export function meta({}: Route.MetaArgs) {
  return [
    { title: "Register - User Management System (RSC)" },
    { name: "description", content: "Create a new account" },
  ];
}

// Server-side action
export async function action({ request }: Route.ActionArgs) {
  const formData = await request.formData();
  const username = formData.get("username") as string;
  const email = formData.get("email") as string;
  const password = formData.get("password") as string;

  const result = await userService.createUser({
    username,
    email,
    password,
  });

  if (!result.success) {
    return { errors: result.errors || {} };
  }

  return redirect("/login");
}

/**
 * Register Page - Server Component
 * 
 * This is a React Server Component that renders on the server.
 * The form itself is a Client Component for interactivity.
 */
export function ServerComponent({ actionData }: Route.ComponentProps) {
  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
      <div className="max-w-md w-full space-y-8">
        <div>
          <h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
            Create Your Account
          </h2>
          <p className="mt-2 text-center text-sm text-gray-600">
            Or{' '}
            <a
              href="/login"
              className="font-medium text-blue-600 hover:text-blue-500"
            >
              sign in to your account
            </a>
          </p>
        </div>

        <div className="mt-8 bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
          <div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-md">
            <p className="text-sm text-blue-800">
              🚀 <strong>React Server Component:</strong> This page is rendered on the server
            </p>
          </div>

          <RegisterForm errors={actionData?.errors} />
        </div>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

3.2 Registration Form (Client Component with Module Federation)

"use client";

import { Suspense, useState } from "react";
import { Form, useNavigation } from "react-router";
import { RemoteFormField, RemoteButton } from "./RemoteComponents";

interface RegisterFormProps {
  errors?: {
    username?: string;
    email?: string;
    password?: string;
  };
}

/**
 * RegisterForm - Client Component with Module Federation
 * 
 * Uses atomic-shared components via Module Federation
 */
export function RegisterForm({ errors }: RegisterFormProps) {
  const [username, setUsername] = useState("");
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const navigation = useNavigation();
  const isSubmitting = navigation.state === "submitting";

  return (
    <Suspense fallback={<div className="text-center">Loading...</div>}>
      <Form method="post" className="space-y-6">
        <RemoteFormField
          label="Username"
          name="username"
          value={username}
          onChange={(e) => setUsername(e.target.value)}
          error={errors?.username}
          required
        />

        <RemoteFormField
          label="Email Address"
          name="email"
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          error={errors?.email}
          required
        />

        <RemoteFormField
          label="Password"
          name="password"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          error={errors?.password}
          required
        />

        <RemoteButton
          type="submit"
          variant="primary"
          className="w-full"
          disabled={isSubmitting}
        >
          {isSubmitting ? "Creating Account..." : "Create Account"}
        </RemoteButton>
      </Form>
    </Suspense>
  );
}
Enter fullscreen mode Exit fullscreen mode

3.3 Login Page

import { redirect } from "react-router";
import type { Route } from "./+types/login";
import { authService } from "~/services/auth.service";
import { sessionService } from "~/services/session.service";
import { LoginForm } from "~/components/LoginForm";

export function meta({}: Route.MetaArgs) {
  return [
    { title: "Login - User Management System (RSC)" },
    { name: "description", content: "Sign in to your account" },
  ];
}

export async function action({ request }: Route.ActionArgs) {
  const formData = await request.formData();
  const email = formData.get("email") as string;
  const password = formData.get("password") as string;

  const authResult = await authService.login(email, password);

  if (!authResult.success || !authResult.user) {
    return { error: authResult.error || "Login failed" };
  }

  const session = await sessionService.createSession(authResult.user.id);

  return redirect("/dashboard", {
    headers: {
      "Set-Cookie": `sessionId=${session.id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${24 * 60 * 60}`,
    },
  });
}

export function ServerComponent({ actionData }: Route.ComponentProps) {
  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
      <div className="max-w-md w-full space-y-8">
        <div>
          <h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
            Sign In to Your Account
          </h2>
          <p className="mt-2 text-center text-sm text-gray-600">
            Or{' '}
            <a
              href="/register"
              className="font-medium text-blue-600 hover:text-blue-500"
            >
              create a new account
            </a>
          </p>
        </div>

        <div className="mt-8 bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
          <div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-md">
            <p className="text-sm text-blue-800">
              🚀 <strong>React Server Component:</strong> Authentication runs on the server
            </p>
          </div>

          <LoginForm error={actionData?.error} />
        </div>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Part 4: Server vs Client Components

4.1 Core Principles

In RSC mode, all components are Server Components by default.

// Server Component (default)
export function ServerComponent() {
  // Runs only on the server
  // Database access, authentication, etc.
  return <div>Server Component</div>;
}

// Client Component (explicit)
"use client";

export function ClientComponent() {
  // Runs on the client
  // useState, useEffect, event handlers, etc.
  return <div>Client Component</div>;
}
Enter fullscreen mode Exit fullscreen mode

4.2 Module Federation Integration

Remote components must always be treated as Client Components.

// ❌ Won't work - Loading remote modules in Server Component
export function ServerComponent() {
  const RemoteButton = lazy(() => loadRemoteModule("./Button"));
  return <RemoteButton>Click</RemoteButton>;
}

// ✅ Correct - Loading remote modules in Client Component
"use client";

export function ClientComponent() {
  const RemoteButton = lazy(() => loadRemoteModule("./Button"));
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <RemoteButton>Click</RemoteButton>
    </Suspense>
  );
}
Enter fullscreen mode Exit fullscreen mode

4.3 Recommended Pattern

Server Components should fetch data and pass it to Client Components.

// routes/dashboard.tsx (Server Component)
export function ServerComponent({ loaderData }: Route.ComponentProps) {
  const { users } = loaderData;

  return (
    <div>
      <h1>Dashboard</h1>
      {/* Fetch data in Server Component */}
      {/* Pass to Client Component for interactivity */}
      <UserList users={users} />
    </div>
  );
}

// components/UserList.tsx (Client Component)
"use client";

export function UserList({ users }) {
  const [selectedUser, setSelectedUser] = useState(null);

  return (
    <div>
      {users.map(user => (
        <RemoteUserCard
          key={user.id}
          user={user}
          onClick={() => setSelectedUser(user)}
        />
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Part 5: Data Layer Implementation

5.1 Database Schema (Drizzle ORM)

import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";

export const users = sqliteTable("users", {
  id: text("id").primaryKey(),
  username: text("username").notNull().unique(),
  email: text("email").notNull().unique(),
  passwordHash: text("password_hash").notNull(),
  createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
  updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
});

export const sessions = sqliteTable("sessions", {
  id: text("id").primaryKey(),
  userId: text("user_id")
    .notNull()
    .references(() => users.id, { onDelete: "cascade" }),
  expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
  createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
});
Enter fullscreen mode Exit fullscreen mode

5.2 Service Layer

import { userRepository } from "~/repositories/user.repository";
import { hashPassword } from "~/utils/password";

export const userService = {
  async createUser(data: { username: string; email: string; password: string }) {
    const errors: Record<string, string> = {};

    // Validation
    if (data.username.length < 3) {
      errors.username = "Username must be at least 3 characters";
    }
    if (!data.email.includes("@")) {
      errors.email = "Please enter a valid email address";
    }
    if (data.password.length < 8) {
      errors.password = "Password must be at least 8 characters";
    }

    if (Object.keys(errors).length > 0) {
      return { success: false, errors };
    }

    // Check existing user
    const existingUser = await userRepository.findByEmail(data.email);
    if (existingUser) {
      return {
        success: false,
        errors: { email: "This email is already registered" },
      };
    }

    // Create user
    const passwordHash = await hashPassword(data.password);
    const user = await userRepository.create({
      username: data.username,
      email: data.email,
      passwordHash,
    });

    return { success: true, user };
  },

  async getAllUsers() {
    return userRepository.findAll();
  },

  async getUserById(id: string) {
    return userRepository.findById(id);
  },

  async updateUser(id: string, data: Partial<{ username: string; email: string }>) {
    return userRepository.update(id, data);
  },

  async deleteUser(id: string) {
    return userRepository.delete(id);
  },
};
Enter fullscreen mode Exit fullscreen mode

5.3 Repository Pattern

import { db } from "~/db";
import { users } from "~/db/schema";
import { eq } from "drizzle-orm";
import { nanoid } from "nanoid";

export const userRepository = {
  async create(data: {
    username: string;
    email: string;
    passwordHash: string;
  }) {
    const user = {
      id: nanoid(),
      username: data.username,
      email: data.email,
      passwordHash: data.passwordHash,
      createdAt: new Date(),
      updatedAt: new Date(),
    };

    await db.insert(users).values(user);
    return user;
  },

  async findById(id: string) {
    const [user] = await db.select().from(users).where(eq(users.id, id));
    return user || null;
  },

  async findByEmail(email: string) {
    const [user] = await db.select().from(users).where(eq(users.email, email));
    return user || null;
  },

  async findAll() {
    return db.select().from(users);
  },

  async update(id: string, data: Partial<{ username: string; email: string }>) {
    await db
      .update(users)
      .set({ ...data, updatedAt: new Date() })
      .where(eq(users.id, id));

    return this.findById(id);
  },

  async delete(id: string) {
    await db.delete(users).where(eq(users.id, id));
  },
};
Enter fullscreen mode Exit fullscreen mode

Part 6: Running the Application

6.1 Start the Remote Application

cd atomic-shared
npm install
npm run build
npm run preview  # http://localhost:5001
Enter fullscreen mode Exit fullscreen mode

6.2 Start the Host Application

cd react-router-rsc-app
npm install
npm run db:generate  # Generate database schema
npm run db:migrate   # Run migrations
npm run dev          # http://localhost:5173
Enter fullscreen mode Exit fullscreen mode

Part 7: Troubleshooting

7.1 Remote Modules Not Loading

// Error: Remote modules can only be loaded on the client side

// Solution: Ensure you're using Client Components
"use client";

import { RemoteButton } from "./RemoteComponents";
Enter fullscreen mode Exit fullscreen mode

7.2 CORS Errors

// vite.config.ts
export default defineConfig({
  server: {
    cors: true,  // Enable CORS
  },
});
Enter fullscreen mode Exit fullscreen mode

7.3 Shared Dependency Version Mismatch

// Use the same versions in package.json
{
  "dependencies": {
    "react": "19.1.1",
    "react-dom": "19.1.1"
  }
}
Enter fullscreen mode Exit fullscreen mode

7.4 TypeScript Errors

// Add type declarations for remote modules
declare module "atomicShared/Button" {
  export interface ButtonProps { /* ... */ }
  export function Button(props: ButtonProps): JSX.Element;
}
Enter fullscreen mode Exit fullscreen mode

Best Practices

1. Component Organization

  • Atoms: Basic building blocks (Button, Input, Label)
  • Molecules: Simple combinations (FormField, Card)
  • Organisms: Complex components (UserForm, UserList)

2. Server vs Client Components

  • Use Server Components for:

    • Data fetching
    • Database queries
    • Authentication
    • Static content
  • Use Client Components for:

    • Interactive features
    • State management
    • Event handlers
    • Remote components

3. Module Federation

  • Keep shared dependencies aligned
  • Use lazy loading for remote components
  • Implement proper error boundaries
  • Add loading states with Suspense

4. Performance Optimization

  • Minimize client-side JavaScript
  • Leverage Server Components for static content
  • Use code splitting effectively
  • Implement proper caching strategies

Conclusion

Combining React Router v7's RSC mode with Module Federation provides powerful benefits:

  1. Optimized Server-Side Rendering: Server Components efficiently handle server-side processing
  2. Component Reusability: Share components across multiple applications via Module Federation
  3. Reduced Bundle Size: Server code stays on the server
  4. Improved Developer Experience: Atomic Design + Micro-frontends architecture

While RSC mode is still experimental, it offers a compelling architecture similar to Next.js App Router but within the React Router ecosystem.

Resources

Sample Code

The complete sample code is available on GitHub:
react-router-rsc-app
my-react-router-ssr-app
my-atomic-shared


If you found this article helpful, please give it a ❤️ and share your thoughts in the comments!

Top comments (0)