React Router v7: Build a Full-Stack App with Framework Mode

React Router v7 marks a major turning point in React development. By merging with Remix, it has become a true full-stack framework capable of handling server-side rendering (SSR), data loading, and mutations — all with a unified and elegant API.
In this tutorial, you will build a complete contacts management application with React Router v7 in Framework Mode: data loading via loaders, mutations via actions, form validation, error handling, and server-side rendering.
Prerequisites
Before starting, make sure you have:
- Node.js 20+ installed on your machine
- npm or pnpm as your package manager
- Basic knowledge of React and TypeScript
- Familiarity with HTTP concepts (GET, POST, PUT, DELETE)
What You'll Build
A contacts management application with the following features:
- Contact list with real-time search
- Create, edit, and delete contacts
- Server-side form validation
- Error handling with Error Boundaries
- Server-side rendering (SSR) for better SEO
Step 1: Create the Project
React Router v7 provides a CLI to initialize a new project in framework mode:
npx create-react-router@latest contacts-app
cd contacts-appThe CLI will ask a few questions. Select the following options:
- Template: Basic
- TypeScript: Yes
- Package manager: npm (or pnpm based on your preference)
Project Structure
Here is the generated structure:
contacts-app/
├── app/
│ ├── routes/
│ │ └── home.tsx
│ ├── root.tsx
│ ├── routes.ts
│ └── app.css
├── react-router.config.ts
├── vite.config.ts
├── tsconfig.json
└── package.json
The key files are:
| File | Purpose |
|---|---|
react-router.config.ts | Global framework configuration |
app/routes.ts | Route definitions for your application |
app/root.tsx | Root layout (HTML shell) |
app/routes/*.tsx | Route modules (components, loaders, actions) |
Step 2: Configure SSR
Open react-router.config.ts and enable server-side rendering:
import type { Config } from "@react-router/dev/config";
export default {
ssr: true,
} satisfies Config;With ssr: true, React Router will:
- Execute loaders on the server before rendering the page
- Send complete HTML to the browser (better SEO, faster load times)
- Hydrate on the client side for interactivity
Step 3: Create the Data Model
Create a file app/data/contacts.ts to simulate a database:
export interface Contact {
id: string;
firstName: string;
lastName: string;
email: string;
phone?: string;
notes?: string;
createdAt: string;
}
const contacts: Map<string, Contact> = new Map();
// Initial data
const initialContacts: Contact[] = [
{
id: "1",
firstName: "Amira",
lastName: "Ben Ali",
email: "amira@example.com",
phone: "+216 50 123 456",
notes: "Frontend developer",
createdAt: "2026-01-15",
},
{
id: "2",
firstName: "Youssef",
lastName: "Mansour",
email: "youssef@example.com",
phone: "+216 55 789 012",
notes: "UX/UI Designer",
createdAt: "2026-02-01",
},
{
id: "3",
firstName: "Leila",
lastName: "Trabelsi",
email: "leila@example.com",
notes: "Project Manager",
createdAt: "2026-02-20",
},
];
initialContacts.forEach((c) => contacts.set(c.id, c));
let nextId = 4;
export function getContacts(query?: string): Contact[] {
let result = Array.from(contacts.values());
if (query) {
const q = query.toLowerCase();
result = result.filter(
(c) =>
c.firstName.toLowerCase().includes(q) ||
c.lastName.toLowerCase().includes(q) ||
c.email.toLowerCase().includes(q)
);
}
return result.sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
}
export function getContact(id: string): Contact | undefined {
return contacts.get(id);
}
export function createContact(
data: Omit<Contact, "id" | "createdAt">
): Contact {
const id = String(nextId++);
const contact: Contact = {
...data,
id,
createdAt: new Date().toISOString().split("T")[0],
};
contacts.set(id, contact);
return contact;
}
export function updateContact(
id: string,
data: Partial<Omit<Contact, "id" | "createdAt">>
): Contact | null {
const existing = contacts.get(id);
if (!existing) return null;
const updated = { ...existing, ...data };
contacts.set(id, updated);
return updated;
}
export function deleteContact(id: string): boolean {
return contacts.delete(id);
}In a real application, you would use a database like PostgreSQL with Prisma or Drizzle ORM. This in-memory module is sufficient for learning React Router v7 concepts.
Step 4: Define Routes
Open app/routes.ts and define your route structure:
import { type RouteConfig, route, index, layout } from "@react-router/dev/routes";
export default [
layout("routes/layout.tsx", [
index("routes/home.tsx"),
route("contacts/new", "routes/contacts-new.tsx"),
route("contacts/:contactId", "routes/contact-detail.tsx"),
route("contacts/:contactId/edit", "routes/contact-edit.tsx"),
route("contacts/:contactId/delete", "routes/contact-delete.tsx"),
]),
] satisfies RouteConfig;Each route is a module that can export a loader, an action, a default component, and an ErrorBoundary.
Step 5: Create the Main Layout
Create app/routes/layout.tsx — the shared layout across all pages:
import { Outlet, Link, useNavigation } from "react-router";
export default function AppLayout() {
const navigation = useNavigation();
const isNavigating = navigation.state !== "idle";
return (
<div className="app-layout">
<header className="app-header">
<Link to="/" className="logo">
<h1>Contacts</h1>
</Link>
<nav>
<Link to="/contacts/new" className="btn btn-primary">
+ New Contact
</Link>
</nav>
</header>
<main className={isNavigating ? "loading" : ""}>
<Outlet />
</main>
<footer className="app-footer">
<p>Built with React Router v7</p>
</footer>
</div>
);
}The <Outlet /> component is essential: it renders the content of the active child route. The useNavigation() hook detects transitions and displays a loading state.
Step 6: Home Page with Loader and Search
Replace the contents of app/routes/home.tsx:
import { useLoaderData, Form, useSearchParams } from "react-router";
import { getContacts } from "~/data/contacts";
import type { Route } from "./+types/home";
export async function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
const query = url.searchParams.get("q") || undefined;
const contacts = getContacts(query);
return { contacts, query };
}
export default function Home() {
const { contacts, query } = useLoaderData<typeof loader>();
const [searchParams] = useSearchParams();
return (
<div className="home-page">
<div className="search-section">
<Form method="get" className="search-form">
<input
type="search"
name="q"
placeholder="Search contacts..."
defaultValue={query || ""}
aria-label="Search contacts"
/>
<button type="submit">Search</button>
</Form>
</div>
<div className="contacts-list">
<h2>
{query
? `Results for "${query}" (${contacts.length})`
: `All Contacts (${contacts.length})`}
</h2>
{contacts.length === 0 ? (
<p className="empty-state">
{query
? "No contacts found for this search."
: "No contacts yet. Create one!"}
</p>
) : (
<ul className="contact-cards">
{contacts.map((contact) => (
<li key={contact.id}>
<a href={`/contacts/${contact.id}`} className="contact-card">
<div className="contact-avatar">
{contact.firstName[0]}
{contact.lastName[0]}
</div>
<div className="contact-info">
<strong>
{contact.firstName} {contact.lastName}
</strong>
<span>{contact.email}</span>
</div>
</a>
</li>
))}
</ul>
)}
</div>
</div>
);
}How Does the Loader Work?
- The
loaderruns on the server before the component renders - It receives the
requestobject with all HTTP request parameters - Returned data is automatically serialized and accessible via
useLoaderData() - The
<Form method="get">component submits a GET form that updates search parameters without a full page reload
Step 7: Creation Form with Action
Create app/routes/contacts-new.tsx:
import { Form, redirect, useActionData, useNavigation } from "react-router";
import { createContact } from "~/data/contacts";
import type { Route } from "./+types/contacts-new";
interface ActionErrors {
firstName?: string;
lastName?: string;
email?: string;
}
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const firstName = String(formData.get("firstName") || "").trim();
const lastName = String(formData.get("lastName") || "").trim();
const email = String(formData.get("email") || "").trim();
const phone = String(formData.get("phone") || "").trim();
const notes = String(formData.get("notes") || "").trim();
// Server-side validation
const errors: ActionErrors = {};
if (!firstName) {
errors.firstName = "First name is required";
}
if (!lastName) {
errors.lastName = "Last name is required";
}
if (!email) {
errors.email = "Email is required";
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
errors.email = "Invalid email format";
}
if (Object.keys(errors).length > 0) {
return { errors };
}
const contact = createContact({
firstName,
lastName,
email,
phone: phone || undefined,
notes: notes || undefined,
});
return redirect(`/contacts/${contact.id}`);
}
export default function NewContact() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
const errors = actionData?.errors;
return (
<div className="form-page">
<h2>New Contact</h2>
<Form method="post" className="contact-form">
<div className="form-group">
<label htmlFor="firstName">First Name *</label>
<input
id="firstName"
name="firstName"
type="text"
required
aria-invalid={errors?.firstName ? true : undefined}
aria-describedby={
errors?.firstName ? "firstName-error" : undefined
}
/>
{errors?.firstName && (
<p id="firstName-error" className="error-message">
{errors.firstName}
</p>
)}
</div>
<div className="form-group">
<label htmlFor="lastName">Last Name *</label>
<input
id="lastName"
name="lastName"
type="text"
required
aria-invalid={errors?.lastName ? true : undefined}
/>
{errors?.lastName && (
<p className="error-message">{errors.lastName}</p>
)}
</div>
<div className="form-group">
<label htmlFor="email">Email *</label>
<input
id="email"
name="email"
type="email"
required
aria-invalid={errors?.email ? true : undefined}
/>
{errors?.email && (
<p className="error-message">{errors.email}</p>
)}
</div>
<div className="form-group">
<label htmlFor="phone">Phone</label>
<input id="phone" name="phone" type="tel" />
</div>
<div className="form-group">
<label htmlFor="notes">Notes</label>
<textarea id="notes" name="notes" rows={3} />
</div>
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={isSubmitting}>
{isSubmitting ? "Creating..." : "Create Contact"}
</button>
<a href="/" className="btn btn-secondary">
Cancel
</a>
</div>
</Form>
</div>
);
}Key Points About This Action
<Form method="post">sends data via a POST request intercepted by React Routeraction()runs on the server and receives the form data- Validation happens server-side — errors are returned and accessible via
useActionData() - On success,
redirect()redirects to the new contact page useNavigation()allows disabling the button during submission
Step 8: Contact Detail Page
Create app/routes/contact-detail.tsx:
import { useLoaderData, Link } from "react-router";
import { getContact } from "~/data/contacts";
import type { Route } from "./+types/contact-detail";
export async function loader({ params }: Route.LoaderArgs) {
const contact = getContact(params.contactId);
if (!contact) {
throw new Response("Contact not found", { status: 404 });
}
return { contact };
}
export default function ContactDetail() {
const { contact } = useLoaderData<typeof loader>();
return (
<div className="detail-page">
<div className="contact-header">
<div className="contact-avatar large">
{contact.firstName[0]}
{contact.lastName[0]}
</div>
<div>
<h2>
{contact.firstName} {contact.lastName}
</h2>
<p className="contact-email">{contact.email}</p>
</div>
</div>
<div className="contact-details">
{contact.phone && (
<div className="detail-row">
<span className="label">Phone</span>
<span>{contact.phone}</span>
</div>
)}
{contact.notes && (
<div className="detail-row">
<span className="label">Notes</span>
<p>{contact.notes}</p>
</div>
)}
<div className="detail-row">
<span className="label">Added on</span>
<span>
{new Date(contact.createdAt).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})}
</span>
</div>
</div>
<div className="detail-actions">
<Link to={`/contacts/${contact.id}/edit`} className="btn btn-primary">
Edit
</Link>
<Link to={`/contacts/${contact.id}/delete`} className="btn btn-danger">
Delete
</Link>
<Link to="/" className="btn btn-secondary">
Back
</Link>
</div>
</div>
);
}
export function ErrorBoundary() {
return (
<div className="error-page">
<h2>Contact Not Found</h2>
<p>The contact you are looking for does not exist or has been deleted.</p>
<Link to="/" className="btn btn-primary">
Back to List
</Link>
</div>
);
}Throwing a Response with a 404 status in a loader automatically triggers the nearest ErrorBoundary. This is the standard pattern for handling missing resources in React Router v7.
Step 9: Edit with Combined Action and Loader
Create app/routes/contact-edit.tsx:
import { Form, redirect, useLoaderData, useActionData, useNavigation } from "react-router";
import { getContact, updateContact } from "~/data/contacts";
import type { Route } from "./+types/contact-edit";
export async function loader({ params }: Route.LoaderArgs) {
const contact = getContact(params.contactId);
if (!contact) {
throw new Response("Contact not found", { status: 404 });
}
return { contact };
}
export async function action({ request, params }: Route.ActionArgs) {
const formData = await request.formData();
const firstName = String(formData.get("firstName") || "").trim();
const lastName = String(formData.get("lastName") || "").trim();
const email = String(formData.get("email") || "").trim();
const phone = String(formData.get("phone") || "").trim();
const notes = String(formData.get("notes") || "").trim();
const errors: Record<string, string> = {};
if (!firstName) errors.firstName = "First name is required";
if (!lastName) errors.lastName = "Last name is required";
if (!email) errors.email = "Email is required";
if (Object.keys(errors).length > 0) {
return { errors };
}
const updated = updateContact(params.contactId, {
firstName,
lastName,
email,
phone: phone || undefined,
notes: notes || undefined,
});
if (!updated) {
throw new Response("Contact not found", { status: 404 });
}
return redirect(`/contacts/${params.contactId}`);
}
export default function EditContact() {
const { contact } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
const errors = actionData?.errors;
return (
<div className="form-page">
<h2>Edit {contact.firstName} {contact.lastName}</h2>
<Form method="post" className="contact-form">
<div className="form-group">
<label htmlFor="firstName">First Name *</label>
<input
id="firstName"
name="firstName"
type="text"
defaultValue={contact.firstName}
required
aria-invalid={errors?.firstName ? true : undefined}
/>
{errors?.firstName && (
<p className="error-message">{errors.firstName}</p>
)}
</div>
<div className="form-group">
<label htmlFor="lastName">Last Name *</label>
<input
id="lastName"
name="lastName"
type="text"
defaultValue={contact.lastName}
required
/>
{errors?.lastName && (
<p className="error-message">{errors.lastName}</p>
)}
</div>
<div className="form-group">
<label htmlFor="email">Email *</label>
<input
id="email"
name="email"
type="email"
defaultValue={contact.email}
required
/>
{errors?.email && (
<p className="error-message">{errors.email}</p>
)}
</div>
<div className="form-group">
<label htmlFor="phone">Phone</label>
<input
id="phone"
name="phone"
type="tel"
defaultValue={contact.phone || ""}
/>
</div>
<div className="form-group">
<label htmlFor="notes">Notes</label>
<textarea
id="notes"
name="notes"
rows={3}
defaultValue={contact.notes || ""}
/>
</div>
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Save Changes"}
</button>
<a href={`/contacts/${contact.id}`} className="btn btn-secondary">
Cancel
</a>
</div>
</Form>
</div>
);
}This route module perfectly illustrates the power of React Router v7: a single file contains the loader (data loading), the action (mutation), and the component (UI). Everything is colocated and type-safe.
Step 10: Delete with Confirmation
Create app/routes/contact-delete.tsx:
import { Form, redirect, useLoaderData } from "react-router";
import { getContact, deleteContact } from "~/data/contacts";
import type { Route } from "./+types/contact-delete";
export async function loader({ params }: Route.LoaderArgs) {
const contact = getContact(params.contactId);
if (!contact) {
throw new Response("Contact not found", { status: 404 });
}
return { contact };
}
export async function action({ params }: Route.ActionArgs) {
deleteContact(params.contactId);
return redirect("/");
}
export default function DeleteContact() {
const { contact } = useLoaderData<typeof loader>();
return (
<div className="confirm-page">
<h2>Confirm Deletion</h2>
<p>
Are you sure you want to delete{" "}
<strong>
{contact.firstName} {contact.lastName}
</strong>
? This action cannot be undone.
</p>
<div className="confirm-actions">
<Form method="post">
<button type="submit" className="btn btn-danger">
Yes, Delete
</button>
</Form>
<a href={`/contacts/${contact.id}`} className="btn btn-secondary">
Cancel
</a>
</div>
</div>
);
}Step 11: Global Error Handling
Modify app/root.tsx to add global error handling:
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
isRouteErrorResponse,
} from "react-router";
import type { Route } from "./+types/root";
import "./app.css";
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
return <Outlet />;
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
if (isRouteErrorResponse(error)) {
return (
<div className="error-container">
<h1>
{error.status} {error.statusText}
</h1>
<p>{error.data}</p>
<a href="/">Back to Home</a>
</div>
);
}
return (
<div className="error-container">
<h1>Unexpected Error</h1>
<p>An unexpected error occurred. Please try again.</p>
<a href="/">Back to Home</a>
</div>
);
}Step 12: Run and Test
Start the development server:
npm run devYour application is accessible at http://localhost:5173. Test the features:
- Home — The contact list is loaded via the SSR loader
- Search — Type a name in the search bar, results update accordingly
- Create — Click "New Contact", fill out the form
- Validation — Submit an incomplete form to see server-side errors
- Edit — Open a contact and modify their information
- Delete — Delete a contact using the confirmation page
Key Concepts to Remember
Loader vs Action
| Concept | HTTP Method | Purpose | Execution |
|---|---|---|---|
loader | GET | Load data | Before rendering |
action | POST, PUT, DELETE | Mutate data | On form submission |
Automatic Revalidation
After each action, React Router automatically revalidates all active loaders. This means that when you create a contact, the list on the home page is automatically updated — without any additional code.
Type Safety
React Router v7 automatically generates types for each route module in the .react-router/types/ folder. This gives you complete typing for:
- Route parameters (
params.contactId) - Loader and action arguments
- Data returned by
useLoaderData()
Troubleshooting
Types Are Not Generated
Run the following command to regenerate types:
npx react-router typegenError "Cannot find module ./+types/"
Make sure tsconfig.json includes the auto-generated paths:
{
"compilerOptions": {
"rootDirs": [".", "./.react-router/types"]
}
}SSR Is Not Working
Verify that ssr: true is set in react-router.config.ts and that you are using loader (not clientLoader).
Next Steps
Now that you have mastered the basics of React Router v7 in framework mode, here are paths to go further:
- Add a database: Replace in-memory storage with Prisma + PostgreSQL or Drizzle ORM + SQLite
- Authentication: Implement a session system using cookies
- Optimistic UI: Use
useFetcher()for optimistic updates without navigation - Deployment: Deploy to Vercel, Cloudflare Workers, or a VPS with Docker
- React Server Components: Explore experimental RSC support in React Router v7
Conclusion
React Router v7 in framework mode represents a modern and unified approach to building full-stack React applications. By combining loaders, actions, and SSR in colocated route modules, it significantly simplifies development while delivering excellent performance and complete type safety.
The concepts learned in this tutorial — server-side data loading, form validation, mutations, and error handling — form the foundation for building robust and performant web applications with React.
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

Build a Real-Time Full-Stack App with Convex and Next.js 15
Learn how to build a real-time full-stack application using Convex and Next.js 15. This tutorial covers schema design, queries, mutations, real-time subscriptions, authentication, and file uploads — all with end-to-end type safety.

Build a Real-Time App with Supabase and Next.js 15: Complete Guide
Learn how to build a full-stack real-time application using Supabase and Next.js 15 App Router. This guide covers authentication, database setup, Row Level Security, and real-time subscriptions.

Building a Full-Stack App with TanStack Start: The Next-Generation React Framework
Learn how to build a complete full-stack application with TanStack Start, the React meta-framework powered by TanStack Router and Vite. This tutorial covers file-based routing, server functions, middleware, authentication, and deployment.