React
|stacknotice.com
15 min left|
0%
|3,000 words
React

Remix Complete Guide: Full-Stack React Framework (2026)

Remix uses web standards — loaders, actions, nested routes — to build full-stack React apps that are fast by default. Complete guide with real patterns for auth, forms, and deployment.

C
Carlos Oliva
Software Developer
July 30, 202615 min read
Share:
Remix Complete Guide: Full-Stack React Framework (2026)

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, error boundaries, loading states — it builds on them.

The result is a framework where most of what you need to learn is web standard knowledge, not Remix-specific API. 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 — the framework manages the request/response cycle.

This guide covers Remix from setup through production patterns: routing, data loading, form handling, authentication, error boundaries, and deployment.

Remix vs Next.js — When to Choose Which

Before diving in, the honest comparison:

Remix advantages:

  • Web standard philosophy — less framework lock-in, concepts transfer
  • Better form handling — actions handle mutations without client state
  • Nested route data loading — parallel, no request waterfalls
  • Error boundaries per route — part of the surface area they isolate
  • Smaller mental model for full-stack React

Next.js advantages:

  • Larger ecosystem and community
  • Better static generation for content-heavy sites
  • More deployment targets (Vercel optimizations, edge network)
  • React Server Components more integrated
  • More mature for complex caching scenarios

Choose Remix if you're building a data-intensive app (dashboards, admin panels, forms-heavy apps) where the request/response model matches how users interact. Choose Next.js if you need strong static generation, ISR, or you're building a marketing site alongside the app. See Next.js App Router Guide for the Next.js approach.

Installation

npx create-remix@latest my-app
cd my-app
npm install
npm run dev

Choose a deployment target during setup — the default (Remix App Server) works for Node.js. Other options: Cloudflare Pages, Vercel, Netlify, AWS.

Project Structure

my-app/
├── app/
│   ├── routes/               # File-based routing
│   │   ├── _index.tsx        # /
│   │   ├── posts._index.tsx  # /posts
│   │   ├── posts.$slug.tsx   # /posts/:slug
│   │   ├── posts.new.tsx     # /posts/new
│   │   └── api.posts.ts      # /api/posts (API route)
│   ├── components/           # Shared components
│   ├── lib/                  # Utilities, db client
│   ├── root.tsx              # Root layout
│   └── entry.server.tsx      # Server entry point
├── public/
├── remix.config.js
└── package.json

File-Based Routing

Remix uses dot notation for nested routes:

routes/
├── _index.tsx                → /
├── about.tsx                 → /about
├── posts._index.tsx          → /posts
├── posts.$slug.tsx           → /posts/:slug  (dynamic)
├── posts.new.tsx             → /posts/new
├── dashboard.tsx             → layout for /dashboard/*
├── dashboard._index.tsx      → /dashboard
├── dashboard.analytics.tsx   → /dashboard/analytics
└── _auth.login.tsx           → /login (no layout nesting)

The _ prefix means "pathless" — the file contributes a layout but not a URL segment. Route files with $ in the name are dynamic parameters.

Loaders — Loading Data

loader functions run on the server for GET requests:

// app/routes/posts._index.tsx
import { json } from '@remix-run/node'
import { useLoaderData, Link } from '@remix-run/react'
import { db } from '~/lib/db.server'
import type { LoaderFunctionArgs } from '@remix-run/node'
 
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,
      select: { id: true, title: true, slug: true, excerpt: true, createdAt: true }
    }),
    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>
      <h1>Posts</h1>
      <ul>
        {posts.map(post => (
          <li key={post.id}>
            <Link to={`/posts/${post.slug}`}>{post.title}</Link>
            <time>{new Date(post.createdAt).toLocaleDateString()}</time>
          </li>
        ))}
      </ul>
 
      <div>
        {page > 1 && <Link to={`?page=${page - 1}`}>Previous</Link>}
        {hasNextPage && <Link to={`?page=${page + 1}`}>Next</Link>}
      </div>
    </div>
  )
}

Key points:

  • loader runs on the server — never exposed to the client
  • useLoaderData<typeof loader>() is fully typed
  • Files ending in .server.ts are never bundled for the client
  • Parallel data with Promise.all — no request waterfall

Dynamic Routes

// app/routes/posts.$slug.tsx
import { json, type LoaderFunctionArgs } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'
import { db } from '~/lib/db.server'
import invariant from 'tiny-invariant'
 
export async function loader({ params }: LoaderFunctionArgs) {
  invariant(params.slug, 'slug is required')
 
  const post = await db.post.findUnique({
    where: { slug: params.slug, published: true }
  })
 
  if (!post) {
    throw new Response('Post not found', { status: 404 })
  }
 
  return json({ post })
}
 
export default function PostDetail() {
  const { post } = useLoaderData<typeof loader>()
 
  return (
    <article>
      <h1>{post.title}</h1>
      <time>{new Date(post.createdAt).toLocaleDateString()}</time>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  )
}

Throwing a Response from a loader shows the nearest error boundary with the correct status code.

Actions — Handling Mutations

action functions handle POST, PUT, PATCH, DELETE requests. This is 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 { db } from '~/lib/db.server'
import { requireUser } from '~/lib/auth.server'
import { z } from 'zod'
 
const CreatePostSchema = z.object({
  title: z.string().min(1, 'Title is required'),
  content: z.string().min(10, 'Content must be at least 10 characters'),
  published: z.boolean().default(false)
})
 
export async function action({ request }: ActionFunctionArgs) {
  const user = await requireUser(request)
 
  const formData = await request.formData()
  const rawData = {
    title: formData.get('title'),
    content: formData.get('content'),
    published: formData.get('published') === 'on'
  }
 
  const result = CreatePostSchema.safeParse(rawData)
  if (!result.success) {
    return json({ errors: result.error.flatten().fieldErrors }, { status: 400 })
  }
 
  const slug = result.data.title
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/(^-|-$)/g, '')
 
  const post = await db.post.create({
    data: { ...result.data, slug, authorId: user.id }
  })
 
  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">
      <div>
        <label htmlFor="title">Title</label>
        <input id="title" name="title" type="text" required />
        {actionData?.errors?.title && (
          <p role="alert">{actionData.errors.title[0]}</p>
        )}
      </div>
 
      <div>
        <label htmlFor="content">Content</label>
        <textarea id="content" name="content" rows={10} required />
        {actionData?.errors?.content && (
          <p role="alert">{actionData.errors.content[0]}</p>
        )}
      </div>
 
      <div>
        <label>
          <input type="checkbox" name="published" />
          Publish immediately
        </label>
      </div>
 
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Creating...' : 'Create Post'}
      </button>
    </Form>
  )
}

<Form method="post"> sends a standard HTML form — Remix intercepts it, runs the action on the server, and handles the redirect. No useState, no onSubmit, no fetch. If JavaScript fails to load, the form still works.

Authentication

Remix uses cookie sessions for auth — standard web platform:

// app/lib/auth.server.ts
import { createCookieSessionStorage, redirect } from '@remix-run/node'
import bcrypt from 'bcryptjs'
import { db } from './db.server'
 
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 login(email: string, password: string) {
  const user = await db.user.findUnique({ where: { email } })
  if (!user) return null
 
  const valid = await bcrypt.compare(password, user.passwordHash)
  if (!valid) return null
 
  return user
}
 
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
}
 
export async function logout(request: Request) {
  const session = await sessionStorage.getSession(request.headers.get('Cookie'))
  return redirect('/login', {
    headers: {
      'Set-Cookie': await sessionStorage.destroySession(session)
    }
  })
}
// app/routes/_auth.login.tsx
import { type ActionFunctionArgs } from '@remix-run/node'
import { Form } from '@remix-run/react'
import { login, createUserSession } from '~/lib/auth.server'
 
export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData()
  const email = formData.get('email') as string
  const password = formData.get('password') as string
  const redirectTo = formData.get('redirectTo') as string || '/dashboard'
 
  const user = await login(email, password)
  if (!user) {
    return { error: 'Invalid email or password' }
  }
 
  return createUserSession(user.id, redirectTo)
}
 
export default function Login() {
  return (
    <Form method="post">
      <input name="email" type="email" placeholder="Email" required />
      <input name="password" type="password" placeholder="Password" required />
      <button type="submit">Log in</button>
    </Form>
  )
}

Error Boundaries

Every route can export an ErrorBoundary that catches errors in that route:

// app/routes/posts.$slug.tsx
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 && (
          <p>This post doesn't exist. <Link to="/posts">Back to posts</Link></p>
        )}
      </div>
    )
  }
 
  return (
    <div>
      <h1>Something went wrong</h1>
      <p>An unexpected error occurred.</p>
    </div>
  )
}

When a loader throws, Remix renders the error boundary instead of the route component. The rest of the page (header, sidebar) continues to work — only the failing route segment is replaced.

Meta — SEO Per Route

// app/routes/posts.$slug.tsx
import type { MetaFunction } from '@remix-run/node'
 
export const meta: MetaFunction<typeof loader> = ({ data }) => {
  if (!data) {
    return [{ title: 'Post not found' }]
  }
 
  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.coverImage }
  ]
}

Pending UI

Remix tracks form submission state automatically:

import { useNavigation, useFetcher } from '@remix-run/react'
 
// Track the overall navigation state
function SubmitButton() {
  const navigation = useNavigation()
  const isSubmitting = navigation.state === 'submitting'
 
  return (
    <button disabled={isSubmitting}>
      {isSubmitting ? 'Saving...' : 'Save'}
    </button>
  )
}
 
// useFetcher for inline actions (without page navigation)
function LikeButton({ postId }: { postId: string }) {
  const fetcher = useFetcher()
  const isLiking = fetcher.state === 'submitting'
 
  return (
    <fetcher.Form method="post" action="/api/like">
      <input type="hidden" name="postId" value={postId} />
      <button type="submit" disabled={isLiking}>
        {isLiking ? '...' : 'Like'}
      </button>
    </fetcher.Form>
  )
}

Deployment

Remix adapters compile for different targets:

# Vercel
npm install @vercel/remix
# Add vercel.json adapter config
 
# Cloudflare Pages
npm install @remix-run/cloudflare-pages
 
# Node.js (any VPS)
npm run build
node ./build/server/index.js

With Docker:

FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY build ./build
COPY public ./public
EXPOSE 3000
CMD ["node", "./build/server/index.js"]

Remix vs React Router v7

React Router v7 introduced a framework mode that's essentially Remix merged into React Router. If you're starting a new project:

  • React Router v7 in framework mode — the successor, actively developed, same mental model
  • Remix — stable, battle-tested, large existing ecosystem of examples

They share the same loader/action model. If you learn one, you know the other. See React Router v7 Guide for the React Router v7 approach.


Remix's strength is the data flow: every route declares its data dependencies in loader, every form submission lands in action, and the framework coordinates the request/response cycle without client-side state management for server data. For apps that are primarily CRUD — admin panels, dashboards, SaaS backends — this model removes an entire category of complexity. The web standards approach also means your Remix knowledge transfers: sessions are cookies, redirects are HTTP 302s, form submissions are POST requests.

#remix#react#typescript#fullstack#webdev
Share:
C
Carlos Oliva
Software Developer · stacknotice.com

Software developer with hands-on experience building production apps with React, Next.js, Angular, TypeScript, and Spring Boot. I write practical guides on Claude Code, AI tools, and modern web development — covering the decisions and trade-offs that senior-level tutorials actually explain.

More about Carlos

Enjoyed this article?

Get weekly insights on Claude Code, React, and AI tools — practical guides for developers who build real things.

No spam. Unsubscribe anytime. By subscribing you agree to our Privacy Policy.