Remix is a full-stack React framework built on web platform fundamentals: Request, Response, FormData, cookies. It doesn't invent abstractions for things the browser already does — form submissions, redirects, loading states — it builds on them.
The data loading model (loaders) and mutation model (actions) map directly to HTTP GET and POST. There's no client-side state library needed for server data.
Installation
npx create-remix@latest my-app
cd my-app
npm install
npm run dev
File-Based Routing
routes/
├── _index.tsx → /
├── posts._index.tsx → /posts
├── posts.$slug.tsx → /posts/:slug (dynamic)
├── posts.new.tsx → /posts/new
├── dashboard.tsx → layout for /dashboard/*
├── dashboard._index.tsx → /dashboard
└── _auth.login.tsx → /login (no URL nesting)
Loaders — Loading Data
loader runs on the server for GET requests:
// app/routes/posts._index.tsx
import { json, type LoaderFunctionArgs } from '@remix-run/node'
import { useLoaderData, Link } from '@remix-run/react'
import { db } from '~/lib/db.server'
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url)
const page = parseInt(url.searchParams.get('page') ?? '1')
const limit = 20
const [posts, total] = await Promise.all([
db.post.findMany({
where: { published: true },
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit
}),
db.post.count({ where: { published: true } })
])
return json({ posts, page, total, hasNextPage: page * limit < total })
}
export default function PostsIndex() {
const { posts, page, hasNextPage } = useLoaderData<typeof loader>()
return (
<div>
{posts.map(post => (
<Link key={post.id} to={`/posts/${post.slug}`}>{post.title}</Link>
))}
{hasNextPage && <Link to={`?page=${page + 1}`}>Next</Link>}
</div>
)
}
Key: useLoaderData<typeof loader>() is fully typed. Files ending in .server.ts never reach the client.
Actions — Handling Mutations
action handles POST, PUT, DELETE — where form submissions land:
// app/routes/posts.new.tsx
import { json, redirect, type ActionFunctionArgs } from '@remix-run/node'
import { Form, useActionData, useNavigation } from '@remix-run/react'
import { z } from 'zod'
const CreatePostSchema = z.object({
title: z.string().min(1, 'Title is required'),
content: z.string().min(10),
published: z.boolean().default(false)
})
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData()
const result = CreatePostSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
published: formData.get('published') === 'on'
})
if (!result.success) {
return json({ errors: result.error.flatten().fieldErrors }, { status: 400 })
}
const post = await db.post.create({ data: result.data })
return redirect(`/posts/${post.slug}`)
}
export default function NewPost() {
const actionData = useActionData<typeof action>()
const navigation = useNavigation()
const isSubmitting = navigation.state === 'submitting'
return (
<Form method="post">
<input name="title" required />
{actionData?.errors?.title && <p>{actionData.errors.title[0]}</p>}
<textarea name="content" rows={10} required />
<button disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Create Post'}
</button>
</Form>
)
}
<Form method="post"> — no useState, no onSubmit, no fetch. If JS fails, the form still works.
Authentication
Cookie sessions — standard web platform:
// app/lib/auth.server.ts
import { createCookieSessionStorage, redirect } from '@remix-run/node'
const sessionStorage = createCookieSessionStorage({
cookie: {
name: '__session',
httpOnly: true,
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 requireUser(request: Request) {
const session = await sessionStorage.getSession(request.headers.get('Cookie'))
const userId = session.get('userId')
if (!userId) throw redirect('/login')
const user = await db.user.findUnique({ where: { id: userId } })
if (!user) throw redirect('/login')
return user
}
Error Boundaries
Every route can handle its own errors:
import { useRouteError, isRouteErrorResponse, Link } from '@remix-run/react'
export function ErrorBoundary() {
const error = useRouteError()
if (isRouteErrorResponse(error)) {
return (
<div>
<h1>{error.status} — {error.statusText}</h1>
{error.status === 404 && <Link to="/posts">Back to posts</Link>}
</div>
)
}
return <div>Something went wrong.</div>
}
When a loader throws, only that route segment shows the error — the rest of the page keeps working.
Pending UI
import { useNavigation, useFetcher } from '@remix-run/react'
// Track overall navigation
const navigation = useNavigation()
const isSubmitting = navigation.state === 'submitting'
// Inline actions without page navigation
function LikeButton({ postId }: { postId: string }) {
const fetcher = useFetcher()
return (
<fetcher.Form method="post" action="/api/like">
<input type="hidden" name="postId" value={postId} />
<button disabled={fetcher.state === 'submitting'}>Like</button>
</fetcher.Form>
)
}
Remix vs Next.js
| Remix | Next.js | |
|---|---|---|
| Data model | loader/action (HTTP) | Server Actions + fetch |
| Forms | Native HTML (action) | Server Actions |
| Error handling | ErrorBoundary per route | error.tsx |
| Static generation | Limited | Excellent (ISR, SSG) |
| Nested routes | Native, parallel loading | Nested layouts |
| Ecosystem | Smaller | Larger |
| Best for | CRUD, dashboards, SaaS | Marketing + app hybrid |
Choose Remix for data-intensive apps (dashboards, admin panels, forms-heavy SaaS). Choose Next.js for content-heavy sites that need static generation.
Full article at stacknotice.com/blog/remix-complete-guide-2026
Top comments (0)