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
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,
},
})
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>
);
}
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}
/>
);
}
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>
);
}
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(),
],
});
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;
}
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;
}
}
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";
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>
);
}
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>
);
}
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>
);
}
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>;
}
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>
);
}
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>
);
}
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(),
});
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);
},
};
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));
},
};
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
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
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";
7.2 CORS Errors
// vite.config.ts
export default defineConfig({
server: {
cors: true, // Enable CORS
},
});
7.3 Shared Dependency Version Mismatch
// Use the same versions in package.json
{
"dependencies": {
"react": "19.1.1",
"react-dom": "19.1.1"
}
}
7.4 TypeScript Errors
// Add type declarations for remote modules
declare module "atomicShared/Button" {
export interface ButtonProps { /* ... */ }
export function Button(props: ButtonProps): JSX.Element;
}
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:
- Optimized Server-Side Rendering: Server Components efficiently handle server-side processing
- Component Reusability: Share components across multiple applications via Module Federation
- Reduced Bundle Size: Server code stays on the server
- 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
- React Router v7 Documentation
- React Server Components
- Module Federation
- @originjs/vite-plugin-federation
- Atomic Design Methodology
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)