DEV Community

Carlos Oliva Pascual
Carlos Oliva Pascual

Posted on • Originally published at stacknotice.com

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, 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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

<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
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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)