Master Remix from the ground up. Learn why Remix was created, understand core concepts like file-based routing, loaders, actions, forms, and data mutations. Build a complete production-ready blog platform covering all Remix fundamentals with best practices.

Remix revolutionizes full-stack React development by providing a framework that embraces web fundamentals while leveraging modern React patterns. Unlike traditional SPAs, Remix enables server-side rendering, progressive enhancement, and seamless data mutations. But why does Remix exist, and what makes it fundamentally different?
In this article, we'll explore Remix's philosophy, understand why it was created, dive deep into core concepts, and build a complete production-ready blog platform that demonstrates all fundamental Remix patterns.
Before Remix, full-stack React developers faced significant challenges:
Ryan Florence and Michael Jackson created Remix with a revolutionary approach:
Remix uses file-based routing similar to Next.js but with a different approach.
// File structure creates routes automatically
app/
├── root.tsx // Root layout
├── routes/
│ ├── _index.tsx // / (home page)
│ ├── about.tsx // /about
│ ├── blog/
│ │ ├── _index.tsx // /blog
│ │ └── $slug.tsx // /blog/:slug (dynamic)
│ └── api/
│ └── posts.tsx // /api/posts (API route)
// Dynamic route with params
// app/routes/blog/$slug.tsx
import { useParams } from '@remix-run/react';
export default function BlogPost() {
const { slug } = useParams();
return <h1>Blog Post: {slug}</h1>;
}Loaders fetch data on the server before rendering.
// app/routes/blog._index.tsx
import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
export async function loader() {
const posts = await fetch('https://api.example.com/posts').then(r => r.json());
return json({ posts });
}
export default function BlogIndex() {
const { posts } = useLoaderData<typeof loader>();
return (
<div>
<h1>Blog Posts</h1>
<ul>
{posts.map(post => (
<li key={post.id}>
<a href={`/blog/${post.slug}`}>{post.title}</a>
</li>
))}
</ul>
</div>
);
}Actions handle form submissions and data mutations.
// app/routes/blog.new.tsx
import { json, redirect } from '@remix-run/node';
import { Form, useActionData } from '@remix-run/react';
export async function action({ request }: { request: Request }) {
if (request.method !== 'POST') {
return json({ error: 'Method not allowed' }, { status: 405 });
}
const formData = await request.formData();
const title = formData.get('title');
const content = formData.get('content');
// Validate
if (!title || !content) {
return json({ error: 'Title and content are required' }, { status: 400 });
}
// Save to database
const post = await fetch('https://api.example.com/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, content }),
}).then(r => r.json());
return redirect(`/blog/${post.slug}`);
}
export default function NewBlogPost() {
const actionData = useActionData<typeof action>();
return (
<Form method="post">
<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>
{actionData?.error && <p className="error">{actionData.error}</p>}
<button type="submit">Create Post</button>
</Form>
);
}HTML forms work without JavaScript.
// app/routes/contact.tsx
import { json } from '@remix-run/node';
import { Form, useActionData, useNavigation } from '@remix-run/react';
export async function action({ request }: { request: Request }) {
if (request.method !== 'POST') {
return json({ error: 'Method not allowed' }, { status: 405 });
}
const formData = await request.formData();
const email = formData.get('email');
const message = formData.get('message');
// Send email
await sendEmail({ email, message });
return json({ success: true });
}
export default function Contact() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const isSubmitting = navigation.state === 'submitting';
return (
<Form method="post">
<div>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
</div>
<div>
<label htmlFor="message">Message</label>
<textarea id="message" name="message" required />
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Sending...' : 'Send'}
</button>
{actionData?.success && <p>Message sent!</p>}
</Form>
);
}Create nested layouts with shared UI.
// app/routes/dashboard._layout.tsx
import { Outlet } from '@remix-run/react';
export default function DashboardLayout() {
return (
<div className="dashboard">
<aside className="sidebar">
<nav>
<a href="/dashboard">Overview</a>
<a href="/dashboard/posts">Posts</a>
<a href="/dashboard/settings">Settings</a>
</nav>
</aside>
<main className="content">
<Outlet />
</main>
</div>
);
}
// app/routes/dashboard._layout.posts.tsx
export default function Posts() {
return <h1>Posts</h1>;
}
// app/routes/dashboard._layout.settings.tsx
export default function Settings() {
return <h1>Settings</h1>;
}Handle errors gracefully with error boundaries.
// app/routes/blog.$slug.tsx
import { useRouteError, isRouteErrorResponse } from '@remix-run/react';
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>{error instanceof Error ? error.message : 'Unknown error'}</p>
</div>
);
}
export default function BlogPost() {
return <h1>Blog Post</h1>;
}Manage metadata for each route.
// app/routes/blog.$slug.tsx
import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
export async function loader({ params }: { params: { slug: string } }) {
const post = await fetch(`https://api.example.com/posts/${params.slug}`).then(r => r.json());
return json({ post });
}
export const meta = ({ data }: { data: typeof loader }) => {
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 default function BlogPost() {
const { post } = useLoaderData<typeof loader>();
return <h1>{post.title}</h1>;
}Handle authentication and sessions.
// app/sessions.server.ts
import { createCookieSessionStorage } from '@remix-run/node';
export const sessionStorage = createCookieSessionStorage({
cookie: {
name: '__session',
httpOnly: true,
maxAge: 60 * 60 * 24 * 7,
path: '/',
sameSite: 'lax',
secrets: [process.env.SESSION_SECRET!],
secure: process.env.NODE_ENV === 'production',
},
});
export const { getSession, commitSession, destroySession } = sessionStorage;
// app/routes/login.tsx
import { json, redirect } from '@remix-run/node';
import { Form } from '@remix-run/react';
import { getSession, commitSession } from '~/sessions.server';
export async function action({ request }: { request: Request }) {
if (request.method !== 'POST') {
return json({ error: 'Method not allowed' }, { status: 405 });
}
const formData = await request.formData();
const email = formData.get('email');
const password = formData.get('password');
// Authenticate user
const user = await authenticateUser(email, password);
if (!user) {
return json({ error: 'Invalid credentials' }, { status: 401 });
}
const session = await getSession();
session.set('userId', user.id);
return redirect('/dashboard', {
headers: {
'Set-Cookie': await commitSession(session),
},
});
}
export default function Login() {
return (
<Form method="post">
<input name="email" type="email" required />
<input name="password" type="password" required />
<button type="submit">Login</button>
</Form>
);
}Create API endpoints without a separate backend.
// app/routes/api.posts.tsx
import { json } from '@remix-run/node';
export async function loader() {
const posts = await getPosts();
return json(posts);
}
export async function action({ request }: { request: Request }) {
if (request.method === 'POST') {
const data = await request.json();
const post = await createPost(data);
return json(post, { status: 201 });
}
return json({ error: 'Method not allowed' }, { status: 405 });
}Update UI optimistically while waiting for server response.
// app/routes/posts.$id.edit.tsx
import { Form, useNavigation, useLoaderData } from '@remix-run/react';
export default function EditPost() {
const { post } = useLoaderData<typeof loader>();
const navigation = useNavigation();
// Optimistic UI - show new values immediately
const formData = navigation.formData;
const optimisticTitle = formData?.get('title') ?? post.title;
const optimisticContent = formData?.get('content') ?? post.content;
return (
<Form method="post">
<input
name="title"
defaultValue={optimisticTitle}
disabled={navigation.state === 'submitting'}
/>
<textarea
name="content"
defaultValue={optimisticContent}
disabled={navigation.state === 'submitting'}
/>
<button type="submit" disabled={navigation.state === 'submitting'}>
{navigation.state === 'submitting' ? 'Saving...' : 'Save'}
</button>
</Form>
);
}Let's build a complete blog platform with Remix.
blog-app/
├── app/
│ ├── root.tsx
│ ├── sessions.server.ts
│ ├── routes/
│ │ ├── _index.tsx
│ │ ├── blog/
│ │ │ ├── _index.tsx
│ │ │ └── $slug.tsx
│ │ ├── blog.new.tsx
│ │ ├── blog.$id.edit.tsx
│ │ ├── login.tsx
│ │ ├── logout.tsx
│ │ ├── dashboard._layout.tsx
│ │ ├── dashboard._layout.posts.tsx
│ │ └── api/
│ │ └── posts.tsx
│ ├── components/
│ │ ├── Header.tsx
│ │ ├── PostCard.tsx
│ │ └── PostForm.tsx
│ ├── styles/
│ │ └── globals.css
│ └── utils/
│ └── db.server.ts
├── public/
├── remix.config.js
├── package.json
└── tsconfig.jsonimport { json } from '@remix-run/node';
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useLoaderData,
} from '@remix-run/react';
import { getSession } from './sessions.server';
import Header from './components/Header';
import styles from './styles/globals.css';
export const links = () => [{ rel: 'stylesheet', href: styles }];
export async function loader({ request }: { request: Request }) {
const session = await getSession(request.headers.get('Cookie'));
const userId = session.get('userId');
return json({ userId });
}
export default function App() {
const { userId } = useLoaderData<typeof loader>();
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
<Header userId={userId} />
<main>
<Outlet />
</main>
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}import { json } from '@remix-run/node';
import { useLoaderData, Link } from '@remix-run/react';
import { getPosts } from '~/utils/db.server';
import PostCard from '~/components/PostCard';
export const meta = () => [
{ title: 'Blog' },
{ name: 'description', content: 'Read our latest blog posts' },
];
export async function loader() {
const posts = await getPosts();
return json({ posts });
}
export default function BlogIndex() {
const { posts } = useLoaderData<typeof loader>();
return (
<div className="blog-page">
<div className="blog-header">
<h1>Blog</h1>
<p>Thoughts on web development and technology</p>
</div>
<div className="posts-grid">
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
</div>
);
}import { json, redirect } from '@remix-run/node';
import { useLoaderData, useRouteError, isRouteErrorResponse } from '@remix-run/react';
import { getPost, deletePost } from '~/utils/db.server';
import { getSession } from '~/sessions.server';
export const meta = ({ data }: { data: typeof loader }) => [
{ title: data.post.title },
{ name: 'description', content: data.post.excerpt },
];
export async function loader({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug);
if (!post) {
throw new Response('Post not found', { status: 404 });
}
return json({ post });
}
export async function action({ request, params }: { request: Request; params: { slug: string } }) {
if (request.method !== 'DELETE') {
return json({ error: 'Method not allowed' }, { status: 405 });
}
const session = await getSession(request.headers.get('Cookie'));
const userId = session.get('userId');
if (!userId) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const post = await getPost(params.slug);
if (post.authorId !== userId) {
return json({ error: 'Forbidden' }, { status: 403 });
}
await deletePost(post.id);
return redirect('/blog');
}
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<div className="error-page">
<h1>{error.status} {error.statusText}</h1>
<p>{error.data}</p>
</div>
);
}
return (
<div className="error-page">
<h1>Error</h1>
<p>{error instanceof Error ? error.message : 'Unknown error'}</p>
</div>
);
}
export default function BlogPost() {
const { post } = useLoaderData<typeof loader>();
return (
<article className="blog-post">
<header className="post-header">
<h1>{post.title}</h1>
<p className="meta">
By {post.author} on {new Date(post.createdAt).toLocaleDateString()}
</p>
</header>
<div className="post-content">
{post.content}
</div>
<footer className="post-footer">
<a href={`/blog/${post.slug}/edit`} className="btn btn-primary">
Edit
</a>
<form method="delete" style={{ display: 'inline' }}>
<button type="submit" className="btn btn-danger">
Delete
</button>
</form>
</footer>
</article>
);
}import { json, redirect } from '@remix-run/node';
import { useActionData } from '@remix-run/react';
import { createPost } from '~/utils/db.server';
import { getSession } from '~/sessions.server';
import PostForm from '~/components/PostForm';
export const meta = () => [
{ title: 'New Post' },
];
export async function action({ request }: { request: Request }) {
if (request.method !== 'POST') {
return json({ error: 'Method not allowed' }, { status: 405 });
}
const session = await getSession(request.headers.get('Cookie'));
const userId = session.get('userId');
if (!userId) {
return redirect('/login');
}
const formData = await request.formData();
const title = formData.get('title') as string;
const excerpt = formData.get('excerpt') as string;
const content = formData.get('content') as string;
// Validate
if (!title || !excerpt || !content) {
return json({ error: 'All fields are required' }, { status: 400 });
}
try {
const post = await createPost({
title,
excerpt,
content,
authorId: userId,
});
return redirect(`/blog/${post.slug}`);
} catch (error) {
return json({ error: 'Failed to create post' }, { status: 500 });
}
}
export default function NewPost() {
const actionData = useActionData<typeof action>();
return (
<div className="new-post-page">
<h1>Create New Post</h1>
<PostForm error={actionData?.error} />
</div>
);
}import { Link, Form } from '@remix-run/react';
export default function Header({ userId }: { userId?: string }) {
return (
<header className="header">
<div className="container">
<Link to="/" className="logo">
Blog
</Link>
<nav className="nav">
<Link to="/blog">Blog</Link>
{userId ? (
<>
<Link to="/dashboard">Dashboard</Link>
<Form method="post" action="/logout">
<button type="submit" className="btn-link">
Logout
</button>
</Form>
</>
) : (
<Link to="/login">Login</Link>
)}
</nav>
</div>
</header>
);
}import { Link } from '@remix-run/react';
interface Post {
id: string;
title: string;
excerpt: string;
slug: string;
author: string;
createdAt: string;
}
export default function PostCard({ post }: { post: Post }) {
return (
<article className="post-card">
<h2>
<Link to={`/blog/${post.slug}`}>{post.title}</Link>
</h2>
<p className="excerpt">{post.excerpt}</p>
<p className="meta">
By {post.author} on {new Date(post.createdAt).toLocaleDateString()}
</p>
<Link to={`/blog/${post.slug}`} className="read-more">
Read More →
</Link>
</article>
);
}import { Form, useNavigation } from '@remix-run/react';
export default function PostForm({ error }: { error?: string }) {
const navigation = useNavigation();
const isSubmitting = navigation.state === 'submitting';
return (
<Form method="post" className="post-form">
{error && <div className="error">{error}</div>}
<div className="form-group">
<label htmlFor="title">Title</label>
<input
id="title"
name="title"
type="text"
required
disabled={isSubmitting}
/>
</div>
<div className="form-group">
<label htmlFor="excerpt">Excerpt</label>
<textarea
id="excerpt"
name="excerpt"
required
disabled={isSubmitting}
rows={3}
/>
</div>
<div className="form-group">
<label htmlFor="content">Content</label>
<textarea
id="content"
name="content"
required
disabled={isSubmitting}
rows={10}
/>
</div>
<button type="submit" disabled={isSubmitting} className="btn btn-primary">
{isSubmitting ? 'Publishing...' : 'Publish'}
</button>
</Form>
);
}// Mock database - replace with real database
const posts = [
{
id: '1',
title: 'Getting Started with Remix',
excerpt: 'Learn the basics of Remix framework',
slug: 'getting-started-with-remix',
content: 'Full post content here...',
author: 'John Doe',
authorId: '1',
createdAt: new Date('2026-02-27'),
},
];
export async function getPosts() {
return posts;
}
export async function getPost(slug: string) {
return posts.find(p => p.slug === slug);
}
export async function createPost(data: any) {
const post = {
id: Math.random().toString(36).substr(2, 9),
...data,
slug: data.title.toLowerCase().replace(/\s+/g, '-'),
createdAt: new Date(),
};
posts.push(post);
return post;
}
export async function updatePost(id: string, data: any) {
const index = posts.findIndex(p => p.id === id);
if (index === -1) return null;
posts[index] = { ...posts[index], ...data };
return posts[index];
}
export async function deletePost(id: string) {
const index = posts.findIndex(p => p.id === id);
if (index === -1) return false;
posts.splice(index, 1);
return true;
}// ✅ Keep loaders focused on data fetching
// ✅ Keep actions focused on mutations
// ✅ Use proper HTTP methods (GET, POST, PUT, DELETE)
// ✅ Return appropriate status codes
// ❌ Avoid mixing data fetching and mutations
// ❌ Don't ignore HTTP semantics
// ❌ Avoid complex logic in loaders/actions// ✅ Use HTML forms for progressive enhancement
// ✅ Validate on both client and server
// ✅ Show loading states during submission
// ✅ Handle errors gracefully
// ❌ Avoid JavaScript-only forms
// ❌ Don't skip server-side validation
// ❌ Avoid poor error messages// ✅ Use error boundaries for route errors
// ✅ Throw responses for expected errors
// ✅ Provide meaningful error messages
// ✅ Handle 404s and other status codes
// ❌ Avoid silent failures
// ❌ Don't expose sensitive error details
// ❌ Avoid generic error messages// ✅ Use parallel data loading with loaders
// ✅ Implement proper caching strategies
// ✅ Use resource routes for API endpoints
// ✅ Optimize database queries
// ❌ Avoid sequential data loading
// ❌ Don't fetch unnecessary data
// ❌ Avoid N+1 query problems// ✅ Validate all user input
// ✅ Check authorization in actions
// ✅ Use secure session management
// ✅ Implement CSRF protection
// ❌ Don't trust user input
// ❌ Avoid exposing sensitive data
// ❌ Don't skip authentication checks// ❌ Wrong - fetching in component
export default function Posts() {
const [posts, setPosts] = useState([]);
useEffect(() => {
fetch('/api/posts').then(r => r.json()).then(setPosts);
}, []);
return <div>{/* render posts */}</div>;
}
// ✅ Correct - fetch in loader
export async function loader() {
const posts = await fetch('https://api.example.com/posts').then(r => r.json());
return json({ posts });
}
export default function Posts() {
const { posts } = useLoaderData<typeof loader>();
return <div>{/* render posts */}</div>;
}// ❌ Wrong - using fetch for mutations
<button onClick={() => fetch('/api/posts', { method: 'POST' })}>
Create
</button>
// ✅ Correct - using forms
<Form method="post">
<input name="title" />
<button type="submit">Create</button>
</Form>// ❌ Wrong - no error handling
export default function Post() {
return <h1>Post</h1>;
}
// ✅ Correct - include error boundary
export function ErrorBoundary() {
const error = useRouteError();
return <div>Error: {error.message}</div>;
}
export default function Post() {
return <h1>Post</h1>;
}// ❌ Wrong - only client validation
<input required />
// ✅ Correct - validate on server
export async function action({ request }: { request: Request }) {
const formData = await request.formData();
const title = formData.get('title');
if (!title) {
return json({ error: 'Title is required' }, { status: 400 });
}
// Process...
}// ❌ Wrong - sequential requests
export async function loader() {
const posts = await getPosts();
const comments = await getComments(); // Waits for posts
return json({ posts, comments });
}
// ✅ Correct - parallel requests
export async function loader() {
const [posts, comments] = await Promise.all([
getPosts(),
getComments(),
]);
return json({ posts, comments });
}# Install Vercel CLI
npm i -g vercel
# Deploy
vercel
# Deploy to production
vercel --prod# Install Fly CLI
curl -L https://fly.io/install.sh | sh
# Launch app
fly launch
# Deploy
fly deployRemix revolutionizes full-stack React development by embracing web fundamentals while leveraging modern React patterns. By combining server-side rendering, progressive enhancement, and declarative data fetching, Remix enables developers to build fast, resilient, and user-friendly applications.
The blog platform we built demonstrates all core Remix concepts in action. Understanding loaders, actions, forms, error boundaries, and progressive enhancement is essential for building modern Remix applications.
Key takeaways:
Next steps:
Remix is the future of full-stack React development. Keep learning, building, and pushing the boundaries of what's possible.