Full-Stack React with Remix: Loaders, Actions, Nested Routes, Validation and Optimistic UI
Key takeaways
Remix is a full-stack React framework built on Web Standards. It simplifies data fetching with loaders, form handling with actions, and works without JavaScript through progressive enhancement.
Introduction
Remix is a full-stack web framework built by the creators of React Router. It embraces web standards and progressive enhancement, making your apps fast, resilient, and accessible.
Built by Ryan Florence and Michael Jackson (creators of React Router), Remix was open-sourced in 2021 and acquired by Shopify in October 2022, which uses it for parts of its own products, including the recommended template for Shopify apps.
A note on versions before you start: with React Router v7 (late 2024), the Remix v2 feature set moved into React Router itself as “framework mode”. Loaders, actions, nested routes, <Form>, fetchers and error boundaries work the same way; imports change from @remix-run/node and @remix-run/react to react-router, and json() is no longer needed because loaders can return plain objects. This article uses the Remix v2 API, which is what most existing Remix code looks like, and everything in it applies to React Router v7 framework mode with those renames.
Why Remix?
Next.js approach:
// Separate data fetching methods
export async function getServerSideProps() { ... }
export async function getStaticProps() { ... }
// API routes in separate files
Remix approach:
// Data fetching in the same file
export async function loader() { ... }
// Form handling in the same file
export async function action() { ... }
The difference is more than file layout. In Remix, each route module owns the whole round trip for its URL: the loader answers GET requests with data, the action answers POST/PUT/DELETE, and the component renders the result. After an action finishes, Remix automatically re-runs the loaders of the routes on the page, so the UI shows fresh server data without any manual cache invalidation. That one rule, “mutations go through actions, and loaders revalidate afterwards”, replaces a lot of the client-side state management that SPAs usually need.
Philosophy
Remix’s core philosophy: “Web Standards First”
Unlike other frameworks that abstract away the web platform, Remix embraces it:
- Forms work without JavaScript — standard HTML
<form>withmethod="POST" - Uses native
fetch(),Response,FormData— no proprietary APIs - Progressive enhancement — app works with JS disabled, enhances with JS enabled
- HTTP caching — respects
Cache-Control,ETag, etc.
When to choose Remix:
- Content-heavy sites — blogs, documentation, e-commerce (better SEO than SPA)
- Forms-heavy apps — admin panels, SaaS dashboards (Remix excels at forms)
- Need resilience — app must work even if JS fails to load
- Want simplicity — collocate data fetching with UI (no separate API routes folder)
When to choose Next.js instead:
- Static sites — Next.js ISR is more mature for blogs/marketing sites
- Vercel ecosystem — Next.js has tighter Vercel integration (Edge Runtime, Image Optimization)
- Larger community — Next.js has a larger ecosystem, more tutorials, courses and third-party integrations
- React Server Components — Next.js App Router is built around RSC today
When to choose Astro instead:
- Mostly static content — Astro ships zero JS by default
- Multi-framework — need Vue/Svelte/Solid components in one site
Getting Started
Create Project
npx create-remix@latest my-app
cd my-app
npm run dev
Choose deployment target:
- Remix App Server (recommended for learning)
- Vercel
- Cloudflare Pages
- Fly.io
Project Structure
my-app/
├── app/
│ ├── routes/
│ │ ├── _index.tsx # Home page (/)
│ │ ├── about.tsx # /about
│ │ └── posts.$id.tsx # /posts/:id
│ ├── root.tsx # Root layout
│ └── entry.client.tsx # Client entry
├── public/
└── remix.config.js
File names are routes. A dot in a file name becomes a slash in the URL (posts.$id.tsx is /posts/:id), a $ marks a dynamic segment available as params.id, and _index is the index route of its parent. Newer Remix v2 projects use Vite, so instead of remix.config.js you will find a vite.config.ts with the Remix plugin; the routing conventions are the same. Files ending in .server.ts are never bundled for the browser, which is the mechanism that keeps database clients and secrets out of client code.
Loaders: Server-Side Data Fetching
Basic Loader
// app/routes/posts.tsx
import type { LoaderFunctionArgs } from '@remix-run/node';
import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
interface Post {
id: number;
title: string;
content: string;
}
export async function loader() {
const response = await fetch('https://api.example.com/posts');
const posts: Post[] = await response.json();
return json({ posts });
}
export default function Posts() {
const { posts } = useLoaderData<typeof loader>();
return (
<div>
<h1>Posts</h1>
<ul>
{posts.map((post) => (
<li key={post.id}>
<a href={`/posts/${post.id}`}>{post.title}</a>
</li>
))}
</ul>
</div>
);
}
The loader runs only on the server: on the initial request as part of server rendering, and on client-side navigations via a fetch that Remix makes to the same URL. That means it can talk to databases and use secrets directly, and it also means whatever it returns is serialized and sent to the browser. Returning a whole database row with a password hash, or an internal API response with fields the page never shows, leaks them to anyone who opens the network tab; select only the fields the UI needs. useLoaderData<typeof loader>() infers the type from the loader, but after serialization, so a Date becomes a string on the client, and the type reflects that.
The plain <a href> works, and is what a no-JavaScript client sees, but in Remix it triggers a full page reload. <Link to> from @remix-run/react renders the same <a> and turns the click into a client-side navigation that fetches only the new route’s data.
Loader with Parameters
// app/routes/posts.$id.tsx
import type { LoaderFunctionArgs } from '@remix-run/node';
import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
export async function loader({ params }: LoaderFunctionArgs) {
const response = await fetch(`https://api.example.com/posts/${params.id}`);
if (!response.ok) {
throw new Response('Not Found', { status: 404 });
}
const post = await response.json();
return json({ post });
}
export default function Post() {
const { post } = useLoaderData<typeof loader>();
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
}
Throwing a Response from a loader is Remix’s way of saying “stop rendering this route and show the error boundary with this status”. The 404 is sent as a real HTTP status, which matters for search engines and caches, and the component never has to handle a missing post. params.id is typed string | undefined and is not validated; if the backend expects a number, check it before building the URL, and remember that the value comes straight from the address bar.
Actions: Form Handling
Basic Action
// app/routes/posts.new.tsx
import type { ActionFunctionArgs } from '@remix-run/node';
import { json, redirect } from '@remix-run/node';
import { Form, useActionData } from '@remix-run/react';
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const title = formData.get('title');
const content = formData.get('content');
// Validation
if (!title || !content) {
return json({ error: 'All fields are required' }, { status: 400 });
}
// Save to database
const response = await fetch('https://api.example.com/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, content }),
});
const post = await response.json();
// Redirect to new post
return redirect(`/posts/${post.id}`);
}
export default function NewPost() {
const actionData = useActionData<typeof action>();
return (
<Form method="post">
<h1>Create New Post</h1>
{actionData?.error && (
<div className="error">{actionData.error}</div>
)}
<div>
<label htmlFor="title">Title</label>
<input id="title" name="title" type="text" required />
</div>
<div>
<label htmlFor="content">Content</label>
<textarea id="content" name="content" required />
</div>
<button type="submit">Create Post</button>
</Form>
);
}
<Form method="post"> renders a normal HTML form. Without JavaScript, the browser submits it to the same URL, the action runs, and the redirect loads the new page; with JavaScript, Remix intercepts the submit, sends the same request with fetch, and applies the result without a reload. Returning a redirect after a successful POST is the classic Post/Redirect/Get pattern: refreshing the next page does not resubmit the form. Returning json({ error }) with a 400 status instead re-renders the page with useActionData holding the error.
Two gaps in this example matter in real code. formData.get() returns FormDataEntryValue | null, which can be a File, so title is not guaranteed to be a string; the Zod section below handles that properly. And the required attributes are only a convenience: anyone can send a POST without them, so the server-side check is the one that counts. The fetch to the API also never checks response.ok, so a failed save would redirect to /posts/undefined.
Update with Optimistic UI
import { useFetcher } from '@remix-run/react';
export function LikeButton({ postId, likes }: { postId: number; likes: number }) {
const fetcher = useFetcher();
// Optimistic update
const displayLikes = fetcher.formData
? likes + 1
: likes;
return (
<fetcher.Form method="post" action={`/posts/${postId}/like`}>
<button type="submit" disabled={fetcher.state !== 'idle'}>
Like {displayLikes} {fetcher.state !== 'idle' && '...'}
</button>
</fetcher.Form>
);
}
A fetcher submits to an action without navigating, which is what you want for small interactions inside a page (likes, toggles, inline edits). fetcher.formData is set while the submission is in flight, so the component can render the expected result immediately; when the action finishes, Remix revalidates the loaders, the fresh likes value arrives from the server, and fetcher.formData clears. If the action fails, the optimistic value simply disappears and the real count is shown, so there is no rollback code to write. The action prop points at another route (/posts/:id/like), which must export an action; a resource route with no component is common for this.
Nested Routes
File-Based Routing
app/routes/
├── _index.tsx # /
├── about.tsx # /about
├── blog.tsx # /blog (layout)
├── blog._index.tsx # /blog (index)
├── blog.$slug.tsx # /blog/:slug
└── blog.new.tsx # /blog/new
Nested Layout
// app/routes/blog.tsx (Parent Layout)
import { Outlet } from '@remix-run/react';
export default function BlogLayout() {
return (
<div className="blog-container">
<nav>
<a href="/blog">All Posts</a>
<a href="/blog/new">New Post</a>
</nav>
<main>
<Outlet /> {/* Child routes render here */}
</main>
</div>
);
}
// app/routes/blog._index.tsx (Child)
export default function BlogIndex() {
return <h1>Blog Home</h1>;
}
Nested routes are the feature that shapes Remix’s performance. For /blog/my-post, Remix matches root.tsx, blog.tsx and blog.$slug.tsx, and runs all their loaders in parallel rather than one after another, avoiding the request waterfall that appears when each component fetches its own data after rendering. On a client-side navigation from one post to another, the layout’s loader does not need to re-run by default, only the changed child’s does. Each level can also have its own ErrorBoundary, so an error in the post content leaves the blog navigation working. As in the loader example, use <Link> or <NavLink> rather than <a href> in the layout; NavLink also adds an active class to the current link.
Error Handling
Error Boundary
// app/routes/posts.$id.tsx
import { useRouteError, isRouteErrorResponse } from '@remix-run/react';
export async function loader({ params }: LoaderFunctionArgs) {
const post = await getPost(params.id);
if (!post) {
throw new Response('Not Found', { status: 404 });
}
return json({ post });
}
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<div>
<h1>{error.status} {error.statusText}</h1>
<p>{error.data}</p>
</div>
);
}
return (
<div>
<h1>Error</h1>
<p>Something went wrong!</p>
</div>
);
}
export default function Post() {
// Component code
}
isRouteErrorResponse distinguishes the two kinds of errors. A thrown Response (the 404 above) is an expected error: its status, status text and body are available, and it is fine to show them. Anything else is an unexpected exception, such as a bug or a database outage; in production, Remix sanitizes those so the client does not see server stack traces, and the full error goes to the server log. If a route has no ErrorBoundary, the error bubbles to the nearest parent that has one, ultimately root.tsx, so define one there to avoid a blank page.
Form Validation
With Zod
npm install zod
import { z } from 'zod';
const PostSchema = z.object({
title: z.string().min(1, 'Title is required').max(100),
content: z.string().min(10, 'Content must be at least 10 characters'),
});
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const result = PostSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
});
if (!result.success) {
return json({
errors: result.error.flatten().fieldErrors
}, { status: 400 });
}
// Save validated data
const post = await createPost(result.data);
return redirect(`/posts/${post.id}`);
}
safeParse turns the loosely typed FormData values into a typed object, and flatten().fieldErrors produces { title?: string[]; content?: string[] }, which the component reads via useActionData to show a message next to each field. A missing field arrives as null, which z.string() rejects with a generic “Expected string, received null”; z.string({ required_error: 'Title is required' }) gives a friendlier message. Returning the submitted values alongside the errors (or using defaultValue from actionData) keeps what the user typed when JavaScript is disabled, since the page is re-rendered by the server. Libraries such as Conform build on this pattern and also handle nested fields and arrays.
Database Integration
Prisma Example
// app/routes/users.tsx
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function loader() {
const users = await prisma.user.findMany({
select: {
id: true,
name: true,
email: true,
},
});
return json({ users });
}
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const user = await prisma.user.create({
data: {
name: formData.get('name') as string,
email: formData.get('email') as string,
},
});
return redirect(`/users/${user.id}`);
}
Two production issues hide in this example. Creating a PrismaClient in a route module means one client per module, and in development, where the dev server reloads modules on every change, each reload creates new clients and connection pools until the database refuses connections (“too many clients already”). The usual fix is a single db.server.ts that creates the client once and stores it on globalThis in development. And the as string casts silence TypeScript without checking anything; a missing field reaches Prisma as null and fails with a validation error from the database layer, so validate with the Zod pattern from the previous section first.
Session Management
// app/utils/session.server.ts
import { createCookieSessionStorage, redirect } from '@remix-run/node';
const sessionStorage = createCookieSessionStorage({
cookie: {
name: '__session',
httpOnly: true,
path: '/',
sameSite: 'lax',
secrets: [process.env.SESSION_SECRET!],
secure: process.env.NODE_ENV === 'production',
},
});
export async function createUserSession(userId: string, redirectTo: string) {
const session = await sessionStorage.getSession();
session.set('userId', userId);
return redirect(redirectTo, {
headers: {
'Set-Cookie': await sessionStorage.commitSession(session),
},
});
}
export async function getUserId(request: Request): Promise<string | null> {
const session = await sessionStorage.getSession(
request.headers.get('Cookie')
);
return session.get('userId');
}
export async function requireUserId(request: Request) {
const userId = await getUserId(request);
if (!userId) {
throw redirect('/login');
}
return userId;
}
Protected Route:
// app/routes/dashboard.tsx
export async function loader({ request }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const user = await getUserById(userId);
return json({ user });
}
A cookie session stores the session data itself in the cookie, signed with SESSION_SECRET so the client cannot tamper with it (but can read it, since signing is not encryption, so store an ID, never sensitive data). secrets is an array to allow rotation: new cookies are signed with the first secret and old ones still verify against the others. httpOnly keeps JavaScript from reading the cookie, and sameSite: 'lax' blocks it from being sent on most cross-site POSTs, which is the main CSRF protection here.
Authorization must be checked in every loader and action that needs it, not only in a parent layout. Because nested loaders run in parallel and a fetcher can call any route’s action directly, a requireUserId in dashboard.tsx does not protect dashboard.settings.tsx’s action. Throwing redirect('/login') from requireUserId works in both loaders and actions and stops the rest of the function. For a login flow, remember to include ?redirectTo= so users land back where they started.
Optimistic UI
Optimistic List Items with a Fetcher
import { useFetcher } from '@remix-run/react';
export function TodoList({ todos }: { todos: Todo[] }) {
const fetcher = useFetcher();
// While the submission is in flight, render the pending item from its form data
const pendingTitle = fetcher.formData?.get('title');
const optimisticTodos =
typeof pendingTitle === 'string'
? [...todos, { id: -1, title: pendingTitle }]
: todos;
return (
<div>
<ul>
{optimisticTodos.map((todo) => (
<li key={todo.id} style={{ opacity: todo.id === -1 ? 0.5 : 1 }}>
{todo.title}
</li>
))}
</ul>
<fetcher.Form method="post">
<input name="title" />
<button type="submit">Add</button>
</fetcher.Form>
</div>
);
}
This is the idiomatic Remix version of optimistic UI: derive the pending state from fetcher.formData instead of storing it. React 19’s useOptimistic can express the same idea, but it only keeps an optimistic value inside a transition, and Remix’s fetcher state already gives you that lifecycle for free. The 'use client' directive often seen in examples belongs to React Server Components frameworks such as Next.js and has no meaning in a Remix route module. Because the list comes from the loader, the real item replaces the pending one as soon as revalidation finishes, and a failed action makes the pending item vanish, which is usually the right feedback combined with an error message from fetcher.data. For several concurrent submissions, useFetchers() returns all in-flight fetchers so each pending item can be rendered.
File Uploads
import { unstable_parseMultipartFormData } from '@remix-run/node';
import { writeFile } from 'fs/promises';
import path from 'path';
export async function action({ request }: ActionFunctionArgs) {
const uploadHandler = async ({ name, data }: any) => {
if (name !== 'file') return undefined;
const chunks = [];
for await (const chunk of data) {
chunks.push(chunk);
}
const buffer = Buffer.concat(chunks);
const filename = `${Date.now()}-${Math.random()}.png`;
const filepath = path.join('public/uploads', filename);
await writeFile(filepath, buffer);
return `/uploads/${filename}`;
};
const formData = await unstable_parseMultipartFormData(request, uploadHandler);
const fileUrl = formData.get('file');
return json({ fileUrl });
}
request.formData() would buffer the whole multipart body in memory, so uploads go through unstable_parseMultipartFormData with an upload handler that receives each file as an async stream of chunks. This handler is a minimal illustration and should not be deployed as is. It collects the whole file into memory anyway, with no size limit, so a large upload can exhaust the server’s memory; unstable_createFileUploadHandler and unstable_createMemoryUploadHandler from @remix-run/node accept a maxPartSize. It names every file .png regardless of content and never checks the type, and it writes into public/, which in many deployments is baked into the build or not writable at all, and serves whatever was uploaded from your own domain. In production, uploads usually stream to object storage (S3, R2, GCS) with a content-type check and a size limit, and the form needs encType="multipart/form-data" or no file is sent at all.
Meta Tags & SEO
import type { MetaFunction } from '@remix-run/node';
export const meta: MetaFunction<typeof loader> = ({ data }) => {
return [
{ title: data.post.title },
{ name: 'description', content: data.post.excerpt },
{ property: 'og:title', content: data.post.title },
{ property: 'og:description', content: data.post.excerpt },
{ property: 'og:image', content: data.post.image },
];
};
export async function loader({ params }: LoaderFunctionArgs) {
const post = await getPost(params.id);
return json({ post });
}
meta receives the loader’s data, so titles and Open Graph tags are rendered on the server and visible to crawlers and link previews without JavaScript. The pitfall is the error path: when the loader throws (a 404, for instance), meta still runs, with data undefined, and data.post.title crashes the error page itself. Guard it: if (!data) return [{ title: 'Not found' }];. Also note that meta in Remix v2 does not merge with parent routes automatically; a child’s meta replaces the parent’s tags, and parent data is available through the matches argument if you want to combine them.
Resource Routes (API Endpoints)
// app/routes/api.posts.ts
import type { LoaderFunctionArgs } from '@remix-run/node';
import { json } from '@remix-run/node';
export async function loader({ request }: LoaderFunctionArgs) {
const posts = await getPosts();
return json({ posts });
}
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const post = await createPost(formData);
return json({ post }, { status: 201 });
}
A route module without a default export is a resource route: it responds with whatever Response the loader or action returns, which makes it the place for JSON endpoints, webhooks, RSS feeds, sitemaps or generated images. Since the page routes already get their data through loaders, you need these far less often than API routes in an SPA; they are mainly for consumers other than your own UI. Links to a resource route need reloadDocument on <Link> (or a plain <a>), because a client-side navigation would try to render it as a page.
Prefetching
import { Link } from '@remix-run/react';
export default function PostList({ posts }: { posts: Post[] }) {
return (
<ul>
{posts.map((post) => (
<li key={post.id}>
{/* Prefetch on hover */}
<Link to={`/posts/${post.id}`} prefetch="intent">
{post.title}
</Link>
</li>
))}
</ul>
);
}
Prefetch options:
none: No prefetchintent: Prefetch on hover/focusrender: Prefetch when link rendersviewport: Prefetch when in viewport
Prefetching loads both the route’s JavaScript module and its loader data, so intent usually makes navigation feel instant with little waste, since users typically hover a link for a moment before clicking. render on a long list of links prefetches data for every one of them, which multiplies load on your loaders and database; reserve it for a few links that are almost always followed. Prefetched data is still revalidated if it is stale by the time the user navigates.
Environment Variables
// .env
DATABASE_URL=postgresql://...
SESSION_SECRET=your-secret-here
// app/utils/env.server.ts
export const env = {
DATABASE_URL: process.env.DATABASE_URL!,
SESSION_SECRET: process.env.SESSION_SECRET!,
};
The .server.ts suffix guarantees this module never ends up in the browser bundle; importing it from client-only code is a build error rather than a silent leak. The non-null assertions (!) only silence TypeScript: if SESSION_SECRET is missing, cookies are signed with undefined and the problem shows up much later. Validating the environment once at startup (for example with a Zod schema that throws a readable error) is safer. Browser code has no process.env; values the client needs must be passed through a loader, typically the root loader, and only non-secret ones.
Loader types, pending UI and fetchers: where Remix code goes subtly wrong
Loader types describe the serialized data
export async function loader() {
return json({ message: 'Hello', createdAt: new Date() });
}
// message: string, createdAt: string (not Date)
const { message, createdAt } = useLoaderData<typeof loader>();
useLoaderData<typeof loader>() infers the type from the loader, but what reaches the component has gone through JSON. Remix v2 reflects that in the inferred type, so a Date returned from the loader arrives as a string, and a Map or class instance loses its methods. Code that calls createdAt.getTime() fails to type-check, which is the correct warning: convert with new Date(createdAt) in the component, or return an ISO string from the loader on purpose.
Pending UI covers navigations, not fetchers
import { useNavigation } from '@remix-run/react';
export default function App() {
const navigation = useNavigation();
const isLoading = navigation.state === 'loading';
return (
<div>
{isLoading && <div>Loading...</div>}
<Outlet />
</div>
);
}
navigation.state moves through idle, submitting (a <Form> with a non-GET method is being posted) and loading (loaders are running for the next page, including the revalidation after an action). A spinner that checks only 'loading' therefore misses the submit phase; check navigation.state !== 'idle' if you want both. Fetcher submissions never change useNavigation() at all, so a global indicator stays idle while a fetcher.Form is saving; read fetcher.state for those, or useFetchers() for all of them.
Fetchers submit without navigating
const fetcher = useFetcher();
// Update without navigation
<fetcher.Form method="post" action="/api/update">
<input name="field" />
<button type="submit">Update</button>
</fetcher.Form>
A <Form> submission is a navigation: if the action lives on another route, the browser ends up on that route, and a history entry is added. For a “like” button, an inline edit, or a newsletter box in the footer, that is wrong, and fetcher.Form posts to the given action route while the user stays on the current page. The action’s return value lands in fetcher.data instead of useActionData(), and page loaders still revalidate afterwards, so the rest of the page picks up the change.
Deployment
Cloudflare Pages
npm install @remix-run/cloudflare
// remix.config.js
module.exports = {
serverModuleFormat: 'esm',
server: './server.ts',
serverBuildPath: 'functions/[[path]].js',
serverPlatform: 'neutral',
};
Vercel
npm install @remix-run/vercel
// remix.config.js
module.exports = {
server: '@remix-run/vercel',
};
These remix.config.js snippets reflect the older, pre-Vite Remix compiler, and @remix-run/vercel is no longer needed on Vercel, which detects Remix and React Router projects automatically. With the Vite-based setup, the deployment target is chosen through adapter packages and Vite plugin presets (for example @remix-run/cloudflare with cloudflareDevProxyVitePlugin), and React Router v7 has equivalent templates. Check the current official template for your host rather than copying configuration from older articles, since this is the part of the ecosystem that changed most between versions. The runtime choice also affects your code: Cloudflare Workers have no Node.js fs or process.env, so the file-upload and environment examples above need their Workers equivalents there.
Frequently Asked Questions (FAQ)
Q. Why is my loader not re-running after I submit a form?
A. Remix revalidates the loaders on the page after an action completes, but only when the submission goes through Remix’s <Form>, useFetcher, or useSubmit. A plain fetch() call to the action or a bare <form> with e.preventDefault() bypasses that, so the UI keeps showing stale loader data. Submit through the Remix APIs, and use shouldRevalidate only when you deliberately want to skip a refetch.