In the last year, I worked extensively with Remix and React Router 7, and I have to say it was a genuinely pleasant experience. If you’re a seasoned web developer like me—someone who was there when AJAX was becoming a thing and struggled to make websites work both with and without JavaScript enabled—you’ll really appreciate the evolution of React Router.
To demonstrate these concepts, I built a Matrix chat application that works almost entirely without JavaScript. You can try it yourself at https://matrix-chat.fly.dev - disable JavaScript in your browser and see how everything works: registration, login, room creation, browsing rooms, and sending messages. The only thing that won’t work is seeing new messages from other users in real-time—you’ll need to refresh the page for that. Then re-enable JavaScript to get the enhanced real-time synchronization.
Why This Matters: Beyond Just “Working Without JavaScript”
The point of building apps that work without JavaScript isn’t just about supporting users who have disabled JavaScript—though that’s important. The real benefits are more practical and impactful:
Escaping the Uncanny Valley
The most compelling reason is performance and user experience. In poor network conditions, your JavaScript bundle might take 10-15 seconds to download and parse. During this time, users are stuck in an “uncanny valley”—they can see your app, but nothing works when they click. Buttons don’t respond, forms don’t submit, links don’t navigate. This can last much longer than you think, especially on slower devices or networks.
With progressive enhancement, your app is immediately functional. Users can start accomplishing their goals right away, even if the enhanced features haven’t loaded yet.
Real Performance Benefits
- Faster Time to Interactive (TTI): Core functionality is available before JavaScript loads
- Reduced Cumulative Layout Shift (CLS): Server-rendered content is stable and complete
- Better First Contentful Paint (FCP): No waiting for client-side rendering
- Resilient to JavaScript failures: If your bundle fails to load or throws an error, the app still works
Accessibility by Default
- Screen readers work immediately: Server-rendered HTML is immediately accessible to assistive technologies
- Keyboard navigation: Standard HTML elements provide built-in keyboard support
- Low-powered devices: Older devices that struggle with heavy JavaScript can still use the app
- Assistive technology compatibility: Progressive enhancement ensures compatibility with a wider range of accessibility tools
SEO and Crawlability
- Search engines get real content: No waiting for JavaScript to render content
- Social media previews work: Open Graph tags and meta descriptions are immediately available
- Web scrapers and bots: Third-party tools can access your content without executing JavaScript
Developer Benefits
- Easier debugging: You can isolate server-side vs client-side issues
- Simpler testing: Core functionality can be tested without complex JavaScript environments
- Better error boundaries: Server-side errors are handled gracefully, client-side errors don’t break core functionality
The Philosophy: Progressive Enhancement as Modern Best Practice
This approach represents a return to web fundamentals, but with modern tools that make it sustainable. HTML provides structure, CSS provides presentation, and JavaScript provides behavior enhancement. The magic happens when these technologies work together harmoniously, with each layer gracefully degrading when the next isn’t available.
Modern React development, especially with server-side rendering and React Server Components, is essentially a sophisticated evolution of this principle. We’re not abandoning the separation of concerns; we’re redefining what those concerns are. Instead of separating HTML, CSS, and JavaScript, we’re now separating server and client responsibilities.
About the Matrix Protocol
For this project, I didn’t want to build a chat backend from scratch—I just needed a backend-for-frontend. So I leveraged the Matrix Protocol (https://matrix.org/), an open standard for real-time communication. This meant I could simply run a Docker image to have a fully functional chat server available.
Matrix is particularly interesting for this demonstration because it provides a real-world API that supports both REST endpoints and real-time features. This gives us the perfect opportunity to showcase how React Router 7 handles both traditional form submissions (which work without JavaScript) and enhanced real-time features (which require JavaScript for polling or WebSocket connections).
React Router 7: The Bridge Between Server and Client
If you’re already familiar with React Router or Remix, feel free to skip this section—but if you’re new to this paradigm, understanding these concepts is crucial to appreciating the progressive enhancement approach.
The Anatomy of a Route
In React Router 7, a route is a file that contains everything needed for a page. Think of it as a complete page definition with three key parts:
loader
→ Executes on the server when the page is accessed with a GET requestaction
→ Executes on the server when the page receives non-GET requests (POST, DELETE, etc.)default export
→ The UI component that renders both server-side and client-side
Server vs. Client Execution
Here’s where it gets interesting: everything runs on the server first, except for React’s useEffect
hooks. The default export (your React component) runs in both environments, but with an important distinction:
- Server-side: Everything executes except
useEffect
hooks - Client-side: The component is hydrated with server data, then
useEffect
hooks execute
This dual execution model is what enables progressive enhancement—your app works completely without JavaScript, but gains enhanced interactivity when JavaScript is available.
A Practical Example
import { json, LoaderFunctionArgs, ActionFunctionArgs } from "react-router";
import { Form, useLoaderData, useNavigation } from "react-router";
// Runs on server for GET requests
export async function loader({ params }: LoaderFunctionArgs) {
const messages = await fetchMessages(params.roomId);
return json({ messages });
}
// Runs on server for POST/PUT/DELETE requests
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const message = formData.get("message");
await sendMessage(message);
return json({ success: true });
}
// Runs on both server and client
export default function ChatRoom() {
const { messages } = useLoaderData<typeof loader>();
const navigation = useNavigation();
// This runs on both server and client
console.log("Rendering chat room");
// This ONLY runs on the client
useEffect(() => {
console.log("Setting up real-time polling");
const interval = setInterval(pollForNewMessages, 1000);
return () => clearInterval(interval);
}, []);
return (
<div>
<div className="messages">
{messages.map(msg => <div key={msg.id}>{msg.text}</div>)}
</div>
{/* This form works with or without JavaScript */}
<Form method="post">
<input name="message" placeholder="Type a message..." />
<button type="submit" disabled={navigation.state === "submitting"}>
{navigation.state === "submitting" ? "Sending..." : "Send"}
</button>
</Form>
</div>
);
}
Key Components and Hooks
React Router provides several crucial components that enable progressive enhancement:
<Form>
→ Creates forms that work both with and without JavaScript<Link>
→ Navigation that enhances to client-side routing when JavaScript is availableuseLoaderData()
→ Access server-loaded datauseNavigation()
→ Track form submission and navigation states for enhanced UX
Progressive Enhancement in Practice
The beauty of this approach becomes clear when you see how fundamental web features are handled. Let’s examine how key functionality works both with and without JavaScript:
Without JavaScript: Nearly Full Functionality
When JavaScript is disabled, the chat application works almost completely because it relies on fundamental web technologies:
- User registration and login → Forms submit via HTTP POST with server-side authentication
- Room creation → Standard form submission creates new Matrix rooms
- Room browsing and search → GET requests with URL parameters for filtering
- Message sending → Forms post messages to the Matrix server
- Navigation → All links work as traditional page navigations
- Server-side rendering → Every page loads with complete HTML content and styling
The only missing feature: Real-time message updates from other users. You’ll need to refresh the page to see new messages. Everything else works exactly the same.
With JavaScript: Enhanced Experience
When JavaScript is available, the same foundation gets enhanced:
- Forms become AJAX calls → Smooth, in-page submission without full page reloads
- Navigation becomes client-side routing → Instant page transitions
- Real-time updates → Polling or WebSocket connections for live message updates
- Loading states → Visual feedback during form submissions and navigation
Feature Comparison: Matrix Chat Implementation
Feature | Traditional SPA Approach | React Router 7 Approach |
---|---|---|
Login/Register | Form submission blocked, AJAX call, manual redirect handling | <Form> component, server action , automatic redirect |
Create Room | Client-side form submission, loading states, error handling | Server action with built-in validation and redirect |
Search Rooms | Debounced client-side search, manual state management | URL-based search with server loader , automatic caching |
Room Messages | useEffect fetch, manual polling, complex state sync | Server loader + client-side polling with useEffect |
Implementation Deep Dive
Let’s examine how each feature was implemented using React Router 7 patterns, showing the actual code that makes progressive enhancement work.
Login/Register
The authentication system demonstrates the power of action
functions and progressive form handling. The same form works with and without JavaScript:
// app/routes/login.tsx
export async function loader({ request }: LoaderFunctionArgs) {
const cookieHeader = request.headers.get("Cookie");
const token = await getTokenFromCookie(cookieHeader);
// Redirect if already authenticated
if (token) {
return redirect("/");
}
return null;
}
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const intent = formData.get("intent");
const username = formData.get("username");
const password = formData.get("password");
if (intent === "login") {
const response = await fetch(`${matrixUrl}/_matrix/client/v3/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "m.login.password",
user: username,
password: password,
}),
});
if (response.ok) {
const { access_token } = await response.json();
return redirect("/", {
headers: {
"Set-Cookie": await createUserSession(access_token),
},
});
}
}
return json({ error: "Invalid credentials" });
}
export default function Login() {
const navigation = useNavigation();
const actionData = useActionData<typeof action>();
return (
<Form method="post">
<input name="username" placeholder="Username" required />
<input name="password" type="password" placeholder="Password" required />
<button name="intent" value="login" type="submit"
disabled={navigation.state === "submitting"}>
{navigation.state === "submitting" ? "Signing in..." : "Sign In"}
</button>
<button name="intent" value="register" type="submit">
Register
</button>
{actionData?.error && <p className="error">{actionData.error}</p>}
</Form>
);
}
Why this works progressively:
- Without JS: Form submits via HTTP POST, page refreshes with results
- With JS: Enhanced with loading states and no page refresh
- Server-side: Authentication logic runs on the server, cookies are secure
Create Room
Room creation shows how server actions can handle complex API integrations while maintaining form simplicity:
// app/routes/createroom.tsx
export async function action({ request }: ActionFunctionArgs) {
const token = await requireUserSession(request);
const formData = await request.formData();
const roomName = formData.get("roomName");
const topic = formData.get("topic");
const isPublic = formData.get("visibility") === "public";
try {
const response = await fetch(`${matrixUrl}/_matrix/client/v3/createRoom`, {
method: "POST",
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: roomName,
topic: topic,
visibility: isPublic ? "public" : "private",
preset: isPublic ? "public_chat" : "private_chat",
}),
});
if (response.ok) {
const { room_id } = await response.json();
return redirect(`/room/${room_id}`);
}
} catch (error) {
return json({ error: "Failed to create room" });
}
}
export default function CreateRoom() {
const navigation = useNavigation();
const actionData = useActionData<typeof action>();
return (
<Form method="post">
<input name="roomName" placeholder="Room Name" required />
<textarea name="topic" placeholder="Room Topic" />
<select name="visibility">
<option value="private">Private</option>
<option value="public">Public</option>
</select>
<button type="submit" disabled={navigation.state === "submitting"}>
{navigation.state === "submitting" ? "Creating..." : "Create Room"}
</button>
{actionData?.error && <p className="error">{actionData.error}</p>}
</Form>
);
}
Progressive enhancement benefits:
- Server validation: Input validation happens on the server first
- Automatic redirect: No manual navigation handling required
- Error handling: Built-in error states without try/catch blocks
Search Rooms
Room search demonstrates URL-driven state management that works perfectly without JavaScript:
// app/routes/browse.tsx
export async function loader({ request }: LoaderFunctionArgs) {
const token = await requireUserSession(request);
const url = new URL(request.url);
const filter = url.searchParams.get("filter");
const body: any = { limit: 100 };
if (filter) {
body.filter = { generic_search_term: filter };
}
const response = await fetch(`${matrixUrl}/_matrix/client/v3/publicRooms`, {
method: "POST",
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
const { chunk: rooms } = await response.json();
return json({ rooms, filter });
}
export default function Browse() {
const { rooms, filter } = useLoaderData<typeof loader>();
const navigation = useNavigation();
return (
<div>
<Form method="get">
<input
name="filter"
placeholder="Search rooms..."
defaultValue={filter || ""}
/>
<button type="submit">Search</button>
</Form>
{navigation.state === "loading" && <p>Searching...</p>}
<div className="rooms">
{rooms.map(room => (
<Link key={room.room_id} to={`/room/${room.room_id}`}>
<h3>{room.name || room.canonical_alias}</h3>
<p>{room.topic}</p>
<span>{room.num_joined_members} members</span>
</Link>
))}
</div>
</div>
);
}
URL-driven state advantages:
- Bookmarkable searches: Share filtered results via URL
- Back button works: Browser navigation handles search history
- Server-side filtering: Fast initial page loads with filtered results
Room Messages and Real-time Updates
The room page shows the perfect blend of server-side data loading and client-side enhancement:
// app/routes/room.tsx
export async function loader({ params, request }: Route.LoaderArgs) {
const cookieHeader = request.headers.get("Cookie");
const token = await matrixTokenCookie.parse(cookieHeader);
const baseUrl = await matrixBaseUrlCookie.parse(cookieHeader);
if (!token || !baseUrl) {
throw redirect("/login");
}
const roomId = params.roomId;
// Fetch room details
const roomResponse = await fetch(`${baseUrl}/_matrix/client/v3/rooms/${roomId}/state/m.room.name`, {
headers: { "Authorization": `Bearer ${token}` }
});
// Fetch initial messages
const messagesResponse = await fetch(
`${baseUrl}/_matrix/client/v3/rooms/${roomId}/messages?dir=b&limit=20`,
{
headers: { "Authorization": `Bearer ${token}` }
}
);
const roomData = await roomResponse.json();
const messagesData = await messagesResponse.json();
// Convert Matrix events to our Message format
const messages = messagesData.chunk
.filter((event: any) => event.type === "m.room.message")
.map((event: any) => ({
id: event.event_id,
sender: event.sender,
body: event.content.body,
timestamp: new Date(event.origin_server_ts)
}))
.reverse();
return {
room: { id: roomId, name: roomData.name || roomId },
messages,
matrixConfig: { token, baseUrl }
};
}
export async function action({ request, params }: Route.ActionArgs) {
const cookieHeader = request.headers.get("Cookie");
const token = await matrixTokenCookie.parse(cookieHeader);
const baseUrl = await matrixBaseUrlCookie.parse(cookieHeader);
const formData = await request.formData();
const message = formData.get("message");
const response = await fetch(
`${baseUrl}/_matrix/client/v3/rooms/${params.roomId}/send/m.room.message/${Date.now()}`,
{
method: "PUT",
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
msgtype: "m.text",
body: message,
}),
}
);
return { success: response.ok };
}
export default function Room() {
const { room, messages: initialMessages, matrixConfig } = useLoaderData<typeof loader>();
const navigation = useNavigation();
// Custom hook for Matrix synchronization - this is where the magic happens!
const { messages, isConnected, error } = useMatrixSync({
roomId: room.id,
initialEvents: initialMessages, // Server-provided initial data
token: matrixConfig.token,
baseUrl: matrixConfig.baseUrl
});
return (
<div>
<div className="room-header">
<h1>{room.name}</h1>
<div className="connection-status">
{isConnected ? "🟢 Connected" : "🔴 Disconnected"}
</div>
</div>
<div className="messages">
{messages.map(message => (
<div key={message.id} className="message">
<strong>{message.sender}</strong>: {message.body}
<time>{message.timestamp.toLocaleTimeString()}</time>
</div>
))}
</div>
<Form method="post">
<input
name="message"
type="text"
placeholder="Type a message..."
required
disabled={navigation.state === "submitting"}
/>
<button type="submit" disabled={navigation.state === "submitting"}>
{navigation.state === "submitting" ? "Sending..." : "Send"}
</button>
</Form>
{error && <div className="error">Connection error: {error}</div>}
</div>
);
}
Now, here’s the crucial part - the custom useMatrixSync
hook:
// app/hooks/useMatrixSync.ts
export function useMatrixSync({ roomId, initialEvents, token, baseUrl }: {
roomId: string;
initialEvents: Message[];
token: string;
baseUrl: string;
}) {
const [messages, setMessages] = useState(initialEvents);
const [isConnected, setIsConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
// This runs on both server and client, but the initial messages
// are provided by the server loader
console.log("useMatrixSync: Setting up with", initialEvents.length, "initial messages");
useEffect(() => {
// This ONLY runs on the client side - perfect for real-time sync!
console.log("useMatrixSync: Starting client-side sync for room", roomId);
let syncToken: string | null = null;
let abortController = new AbortController();
async function sync() {
try {
const syncUrl = new URL(`${baseUrl}/_matrix/client/v3/sync`);
syncUrl.searchParams.set("timeout", "30000");
syncUrl.searchParams.set("filter", JSON.stringify({
room: { rooms: [roomId] }
}));
if (syncToken) {
syncUrl.searchParams.set("since", syncToken);
}
const response = await fetch(syncUrl.toString(), {
headers: { "Authorization": `Bearer ${token}` },
signal: abortController.signal
});
if (response.ok) {
const syncData = await response.json();
setIsConnected(true);
setError(null);
// Process new messages
const roomData = syncData.rooms?.join?.[roomId] || syncData.rooms?.timeline?.[roomId];
if (roomData?.timeline?.events) {
const newMessages = roomData.timeline.events
.filter((event: any) => event.type === "m.room.message")
.map((event: any) => ({
id: event.event_id,
sender: event.sender,
body: event.content.body,
timestamp: new Date(event.origin_server_ts)
}));
setMessages(current => [...current, ...newMessages]);
}
syncToken = syncData.next_batch;
// Continue syncing
setTimeout(sync, 100);
}
} catch (error) {
if (error.name !== "AbortError") {
setIsConnected(false);
setError(error.message);
// Retry after delay
setTimeout(sync, 5000);
}
}
}
sync();
return () => {
abortController.abort();
};
}, [roomId, token, baseUrl]);
return { messages, isConnected, error };
}
The Custom Hook Magic: Server-Side Execution
Here’s the fascinating part about the useMatrixSync
hook that perfectly demonstrates React Router 7’s dual execution model:
// This part runs on BOTH server and client
const [messages, setMessages] = useState(initialEvents); // ✅ Server & Client
console.log("Setting up with", initialEvents.length, "initial messages"); // ✅ Server & Client
// This part runs ONLY on the client
useEffect(() => { // ❌ Server (skipped), ✅ Client only
console.log("Starting client-side sync");
// All the real-time synchronization logic
}, [roomId]);
Why the hook accepts initialEvents
:
The custom hook needs initialEvents
from the loader because when it runs on the server during SSR, the useEffect
won’t execute. The server-side execution only initializes the state with the data we fetched in the loader. Then, when the component hydrates on the client, the useEffect
kicks in and starts the real-time synchronization from where the server left off.
This creates a seamless handoff:
- Server: Fetches initial messages, passes them to the hook, hook initializes state
- Client: Hook hydrates with server state, then
useEffect
begins real-time sync - Result: No gap in data, no loading states, perfect progressive enhancement
Perfect progressive enhancement:
- Server-side: Initial messages load immediately, forms work without JS
- Client-side: Real-time updates and enhanced UX when JS is available
- Graceful degradation: If polling fails, manual refresh still works
- Hook design: Accepts server data to bridge server-client execution seamlessly
The Evolution of Separation of Concerns
Here’s where things get philosophically interesting. The traditional “separation of concerns” in web development meant separating technologies: HTML in one file, CSS in another, JavaScript in a third. We were taught that mixing these was evil—it created “spaghetti code” that was hard to maintain.
But React and modern frameworks challenged this thinking. Instead of separating by technology, we began separating by feature or component. A button component might contain its HTML structure, CSS styling, and JavaScript behavior all in one place. This felt wrong at first, but it proved to be more maintainable for complex applications.
React Router 7 and React Server Components represent the next evolution: separating by execution environment. We’re not just thinking about what runs where, but when it runs and why. This creates a new kind of separation of concerns:
- Server concerns: Data fetching, authentication, business logic, initial rendering
- Client concerns: User interactions, real-time updates, enhanced UX, local state
This isn’t abandoning separation of concerns—it’s refining what those concerns actually are. We’re returning to the web’s original progressive enhancement model, but with modern tools that make it sustainable and developer-friendly.
Why This Matters for Backend-for-Frontend
In the past, when I built React apps with a separate backend-for-frontend (often in PHP), I had to:
- Manually configure Webpack to split code by route
- Manually type every HTTP call
- Keep frontend and backend logic in sync across different languages
- Handle loading states, error states, and form submissions in every component
React Router 7 eliminates this friction. The loader and action functions run on the server, giving you the backend-for-frontend logic right alongside your components. There’s no context switching between languages, no manual API typing, and no wondering whether your frontend and backend are in sync.
It’s not just about reducing work—it’s about reducing the cognitive overhead of building web applications.
Key Takeaways
Building this Matrix chat application with React Router 7 reinforced several important principles:
More Belongs in Loaders and Actions Than You Think
Initially, I tried to keep server-side logic minimal, but I discovered that pushing more logic into loaders and actions creates a better user experience. Authentication checks, data validation, business logic, and even complex data transformations work better on the server where they can fail fast and provide immediate feedback. I even ditched entirely the typescript sdk for matrix in favour of doing every query and command in the loader / action, and having a bunch of custom hooks which would listend for events comming.
Embrace URL State
Put as much state as possible in the URL—it’s easier for both developers and users. Room IDs, filter states, pagination, and even UI preferences can live in the URL. This makes the application more shareable, bookmarkable, and debuggable. React Router’s nested routing makes this natural and maintainable.
Progressive Enhancement Isn’t Just Nice-to-Have
When your app works without JavaScript, you’ve built something fundamentally more resilient. It will work on slow networks, older devices, and in environments where JavaScript fails to load. But more importantly, you’ve built something that aligns with the grain of the web rather than fighting against it.
The Framework Handles the Hard Parts
React Router 7 eliminates so much boilerplate around form handling, loading states, error boundaries, and data synchronization. You write declarative loaders and actions, and the framework handles optimistic updates, race conditions, and state management automatically.
Conclusion
React is evolving back toward the web’s fundamental principles, and that’s not a step backward—it’s a step forward with better tools. Server-side rendering, progressive enhancement, and unobtrusive JavaScript aren’t old-fashioned ideas; they’re timeless principles that modern frameworks are finally making approachable.
The Matrix chat application demonstrates that you can build rich, interactive experiences without sacrificing accessibility, performance, or reliability. When your React app works with JavaScript disabled, you know you’ve built something that truly embraces the web platform.
Try it yourself: disable JavaScript in your browser and see how many of your favorite web applications still work. Then imagine building your next project with progressive enhancement from day one. The web—and your users—will thank you for it.