We use cookies to enhance your experience on the site
CodeWorlds
Back to collections
Guide51 min read

Remix

Remix is a fullstack React framework focused on web standards, progressive enhancement, and nested routing. Complete guide to loaders, actions, and forms.

Remix - Fullstack Web Framework oparty na Web Standards

Czym jest Remix?

Remix to nowoczesny fullstack framework React stworzony przez twórców React Router (Michael Jackson i Ryan Florence). W przeciwieństwie do innych frameworków React, Remix stawia na wykorzystanie natywnych mechanizmów przeglądarki i web standards zamiast wymyślania własnych abstrakcji. Filozofia Remix brzmi: "use the platform" - wykorzystaj to, co przeglądarka już potrafi.

Remix został przejęty przez Shopify w 2022 roku, co zapewniło mu solidne finansowanie i wsparcie enterprise. Framework jest używany przez takie firmy jak NASA, Shopify, Netflix i wiele innych do budowania wydajnych, dostępnych aplikacji webowych.

Kluczowa różnica Remix od innych frameworków polega na podejściu do data loading i mutations - zamiast state management po stronie klienta, Remix zachęca do wykorzystania serwera do zarządzania stanem aplikacji. Formularze działają natywnie bez JavaScript, a cała aplikacja jest progressive enhanced.

Dlaczego Remix?

Kluczowe zalety frameworka

  1. Web Standards First - HTTP, formularze HTML, cookies działają tak jak powinny
  2. Progressive Enhancement - Aplikacja działa bez JavaScript i ulepsza się gdy JS jest dostępny
  3. Nested Routes - Hierarchiczna struktura route'Ăłw z automatycznym dziedziczeniem layoutĂłw
  4. Data Loading - Loaders ładują dane równolegle dla wszystkich nested routes
  5. Mutations - Actions obsługują formularze bez client-side state management
  6. Error Boundaries - Granularne error handling na poziomie kaĹĽdego route'a
  7. SEO Friendly - Pełne SSR z kontrolą nad meta tagami i headers
  8. Type Safety - Pełna integracja z TypeScript

Remix vs Next.js vs Astro

CechaRemixNext.jsAstro
RoutingNested, file-basedFile-based, App RouterFile-based
Data LoadingLoaders (server)Server Components, fetchAstro.props
MutationsActions + FormsServer ActionsAPI endpoints
Progressive Enhancement✅ Natywne❌ Wymaga JSCzęściowe
Streamingâś…âś…âś…
Edge Runtimeâś…âś…âś…
Static ExportCzęściowe✅✅
React Server Components❌✅❌
Learning CurveĹšredniaWysokaNiska

Kiedy wybrać Remix?

Remix jest idealny gdy:

  • Budujesz aplikacje z duĹĽÄ… iloĹ›ciÄ… formularzy i interakcji
  • Potrzebujesz progressive enhancement (dostÄ™pność!)
  • Chcesz wykorzystać web standards zamiast abstrakcji
  • Masz zĹ‚oĹĽonÄ… hierarchiÄ™ layoutĂłw
  • ZaleĹĽy Ci na SEO i performance
  • Chcesz uniknąć client-side state management

RozwaĹĽ alternatywy gdy:

  • Potrzebujesz peĹ‚nego static site generation (Astro, Next.js)
  • Preferujesz React Server Components (Next.js)
  • Budujesz prostÄ… stronÄ™ bez interakcji (Astro)

Instalacja i konfiguracja

Tworzenie nowego projektu

Code
Bash
# Oficjalny CLI
npx create-remix@latest my-remix-app

# Z szablonem
npx create-remix@latest --template remix-run/indie-stack my-app
npx create-remix@latest --template remix-run/blues-stack my-app
npx create-remix@latest --template remix-run/grunge-stack my-app

# Minimalna instalacja
npx create-remix@latest --template remix-run/remix/templates/remix

Dostępne oficjalne szablony (Stacks)

Code
Bash
# Indie Stack - SQLite, Tailwind, Fly.io
# Idealny do małych projektów i prototypów
npx create-remix@latest --template remix-run/indie-stack

# Blues Stack - PostgreSQL, Docker, Fly.io
# Dla większych aplikacji produkcyjnych
npx create-remix@latest --template remix-run/blues-stack

# Grunge Stack - DynamoDB, AWS Lambda
# Dla serverless na AWS
npx create-remix@latest --template remix-run/grunge-stack

Struktura projektu

Code
TEXT
my-remix-app/
├── app/
│   ├── routes/                 # File-based routing
│   │   ├── _index.tsx         # /
│   │   ├── about.tsx          # /about
│   │   ├── posts._index.tsx   # /posts
│   │   ├── posts.$id.tsx      # /posts/:id
│   │   └── posts.new.tsx      # /posts/new
│   ├── components/            # React components
│   ├── utils/                 # Utility functions
│   ├── styles/                # CSS files
│   ├── entry.client.tsx       # Client entry point
│   ├── entry.server.tsx       # Server entry point
│   └── root.tsx               # Root layout
├── public/                    # Static assets
├── remix.config.js            # Remix configuration
├── package.json
└── tsconfig.json

Konfiguracja Remix

JSremix.config.js
JavaScript
// remix.config.js
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
  // Ignoruj routes w tych folderach
  ignoredRouteFiles: ["**/.*"],

  // Server runtime
  serverModuleFormat: "esm",

  // Future flags
  future: {
    v2_dev: true,
    v2_errorBoundary: true,
    v2_headers: true,
    v2_meta: true,
    v2_normalizeFormMethod: true,
    v2_routeConvention: true,
  },

  // Tailwind support
  tailwind: true,
  postcss: true,

  // Server build target
  serverBuildTarget: "vercel",
  server: process.env.NODE_ENV === "development" ? undefined : "./server.js",
}

Routing w Remix

File-based Routing

Remix uĹĽywa konwencji nazewnictwa plikĂłw do definiowania routes:

Code
TEXT
app/routes/
├── _index.tsx              # GET /
├── about.tsx               # GET /about
├── contact.tsx             # GET /contact
│
├── posts._index.tsx        # GET /posts
├── posts.$id.tsx           # GET /posts/:id (dynamic)
├── posts.new.tsx           # GET /posts/new
├── posts.$id.edit.tsx      # GET /posts/:id/edit
│
├── blog_.tsx               # Layout dla /blog/* bez nested UI
├── blog_.$slug.tsx         # GET /blog/:slug (flat route)
│
├── ($lang).about.tsx       # GET /about lub GET /pl/about (optional)
├── files.$.tsx             # GET /files/* (splat/catch-all)
│
├── _auth.tsx               # Layout bez URL segmentu
├── _auth.login.tsx         # GET /login
├── _auth.register.tsx      # GET /register
│
├── api.users.ts            # GET/POST /api/users (resource route)
└── healthcheck.ts          # GET /healthcheck

Konwencje nazewnictwa

PatternPrzykład plikuURLOpis
_index_index.tsx/Index route
nazwaabout.tsx/aboutStatic route
$paramposts.$id.tsx/posts/123Dynamic segment
. (dot)posts.new.tsx/posts/newNested route
_ (prefix)_auth.login.tsx/loginPathless layout
_ (suffix)blog_.tsx-Layout escape
($param)($lang).about.tsx/about lub /pl/aboutOptional segment
$files.$.tsx/files/*Splat route

Nested Routes i Layouts

TSapp/routes/dashboard.tsx
TypeScript
// app/routes/dashboard.tsx - Parent layout
import { Outlet } from '@remix-run/react'

export default function DashboardLayout() {
  return (
    <div className="dashboard">
      <nav className="sidebar">
        <NavLink to="/dashboard">Overview</NavLink>
        <NavLink to="/dashboard/analytics">Analytics</NavLink>
        <NavLink to="/dashboard/settings">Settings</NavLink>
      </nav>

      <main className="content">
        {/* Child routes render here */}
        <Outlet />
      </main>
    </div>
  )
}
TSapp/routes/dashboard._index.tsx
TypeScript
// app/routes/dashboard._index.tsx - /dashboard
export default function DashboardIndex() {
  return <h1>Dashboard Overview</h1>
}

// app/routes/dashboard.analytics.tsx - /dashboard/analytics
export default function DashboardAnalytics() {
  return <h1>Analytics</h1>
}

// app/routes/dashboard.settings.tsx - /dashboard/settings
export default function DashboardSettings() {
  return <h1>Settings</h1>
}

Pathless Layouts (Grouping)

TSapp/routes/_auth.tsx
TypeScript
// app/routes/_auth.tsx - Layout dla auth pages bez /auth w URL
import { Outlet } from '@remix-run/react'

export default function AuthLayout() {
  return (
    <div className="auth-container">
      <div className="auth-card">
        <img src="/logo.svg" alt="Logo" />
        <Outlet />
      </div>
    </div>
  )
}

// app/routes/_auth.login.tsx -> /login
// app/routes/_auth.register.tsx -> /register
// app/routes/_auth.forgot-password.tsx -> /forgot-password

Loaders - Server-side Data Loading

Podstawowy loader

TSapp/routes/posts._index.tsx
TypeScript
// app/routes/posts._index.tsx
import type { LoaderFunctionArgs } from '@remix-run/node'
import { json } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'
import { db } from '~/utils/db.server'

// Loader wykonuje siÄ™ na serwerze przy kaĹĽdym request
export async function loader({ request }: LoaderFunctionArgs) {
  const url = new URL(request.url)
  const page = parseInt(url.searchParams.get('page') || '1')
  const limit = 10

  const posts = await db.post.findMany({
    where: { published: true },
    orderBy: { createdAt: 'desc' },
    skip: (page - 1) * limit,
    take: limit,
    include: {
      author: {
        select: { name: true, avatar: true }
      }
    }
  })

  const total = await db.post.count({ where: { published: true } })

  // json() ustawia Content-Type i serializuje dane
  return json({
    posts,
    pagination: {
      page,
      totalPages: Math.ceil(total / limit),
      total
    }
  })
}

export default function PostsIndex() {
  // useLoaderData zwraca dane z loadera z pełnym type inference
  const { posts, pagination } = useLoaderData<typeof loader>()

  return (
    <div>
      <h1>Blog Posts</h1>

      <ul>
        {posts.map(post => (
          <li key={post.id}>
            <Link to={`/posts/${post.id}`}>
              <h2>{post.title}</h2>
              <p>By {post.author.name}</p>
            </Link>
          </li>
        ))}
      </ul>

      <Pagination {...pagination} />
    </div>
  )
}

Dynamic Routes z parametrami

TSapp/routes/posts.$id.tsx
TypeScript
// app/routes/posts.$id.tsx
import type { LoaderFunctionArgs } from '@remix-run/node'
import { json } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'
import { db } from '~/utils/db.server'

export async function loader({ params }: LoaderFunctionArgs) {
  // params.id pochodzi z nazwy pliku ($id)
  const post = await db.post.findUnique({
    where: { id: params.id },
    include: {
      author: true,
      comments: {
        include: { author: true },
        orderBy: { createdAt: 'desc' }
      }
    }
  })

  // Rzuć Response dla 404
  if (!post) {
    throw new Response('Post not found', { status: 404 })
  }

  // Możesz też sprawdzić uprawnienia
  // if (!post.published && !isAuthor) {
  //   throw new Response('Forbidden', { status: 403 })
  // }

  return json({ post })
}

export default function PostPage() {
  const { post } = useLoaderData<typeof loader>()

  return (
    <article>
      <h1>{post.title}</h1>
      <div className="meta">
        <span>By {post.author.name}</span>
        <time>{new Date(post.createdAt).toLocaleDateString()}</time>
      </div>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />

      <section className="comments">
        <h2>Comments ({post.comments.length})</h2>
        {post.comments.map(comment => (
          <Comment key={comment.id} {...comment} />
        ))}
      </section>
    </article>
  )
}

Parallel Data Loading

Remix automatycznie ładuje dane dla wszystkich nested routes równolegle:

TSapp/routes/dashboard.tsx
TypeScript
// app/routes/dashboard.tsx
export async function loader({ request }: LoaderFunctionArgs) {
  const user = await requireUser(request)
  return json({ user })
}

// app/routes/dashboard.analytics.tsx
export async function loader({ request }: LoaderFunctionArgs) {
  // Ten loader wykonuje się RÓWNOLEGLE z parent loader
  const analytics = await getAnalytics()
  return json({ analytics })
}

// Oba loadery wykonują się jednocześnie!
// Dashboard Layout otrzymuje user, Analytics Page otrzymuje analytics

Loader z autentykacjÄ…

TSapp/utils/session.server.ts
TypeScript
// 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 getUser(request: Request) {
  const session = await sessionStorage.getSession(
    request.headers.get('Cookie')
  )
  const userId = session.get('userId')

  if (!userId) return null

  return db.user.findUnique({ where: { id: userId } })
}

export async function requireUser(request: Request) {
  const user = await getUser(request)

  if (!user) {
    throw redirect('/login')
  }

  return user
}

// app/routes/dashboard.tsx
export async function loader({ request }: LoaderFunctionArgs) {
  const user = await requireUser(request) // Redirect jeśli nie zalogowany

  const [stats, notifications] = await Promise.all([
    getStats(user.id),
    getNotifications(user.id)
  ])

  return json({ user, stats, notifications })
}

Actions - Obsługa Formularzy i Mutations

Podstawowy action

TSapp/routes/posts.new.tsx
TypeScript
// app/routes/posts.new.tsx
import type { ActionFunctionArgs } from '@remix-run/node'
import { redirect, json } from '@remix-run/node'
import { Form, useActionData } from '@remix-run/react'
import { requireUser } from '~/utils/session.server'
import { db } from '~/utils/db.server'

export async function action({ request }: ActionFunctionArgs) {
  const user = await requireUser(request)

  // Pobierz dane z formularza
  const formData = await request.formData()
  const title = formData.get('title')
  const content = formData.get('content')
  const published = formData.get('published') === 'on'

  // Walidacja
  const errors: Record<string, string> = {}

  if (!title || typeof title !== 'string' || title.length < 3) {
    errors.title = 'Title must be at least 3 characters'
  }

  if (!content || typeof content !== 'string' || content.length < 10) {
    errors.content = 'Content must be at least 10 characters'
  }

  if (Object.keys(errors).length > 0) {
    return json({ errors }, { status: 400 })
  }

  // Zapisz do bazy
  const post = await db.post.create({
    data: {
      title: title as string,
      content: content as string,
      published,
      authorId: user.id,
    },
  })

  // Redirect po sukcesie
  return redirect(`/posts/${post.id}`)
}

export default function NewPost() {
  const actionData = useActionData<typeof action>()

  return (
    <div>
      <h1>Create New Post</h1>

      {/* Form automatycznie wysyła POST do tego samego URL */}
      <Form method="post">
        <div>
          <label htmlFor="title">Title</label>
          <input
            type="text"
            id="title"
            name="title"
            required
          />
          {actionData?.errors?.title && (
            <p className="error">{actionData.errors.title}</p>
          )}
        </div>

        <div>
          <label htmlFor="content">Content</label>
          <textarea
            id="content"
            name="content"
            rows={10}
            required
          />
          {actionData?.errors?.content && (
            <p className="error">{actionData.errors.content}</p>
          )}
        </div>

        <div>
          <label>
            <input type="checkbox" name="published" />
            Publish immediately
          </label>
        </div>

        <button type="submit">Create Post</button>
      </Form>
    </div>
  )
}

Multiple Actions w jednym route

TSapp/routes/posts.$id.tsx
TypeScript
// app/routes/posts.$id.tsx
import { Form } from '@remix-run/react'

export async function action({ request, params }: ActionFunctionArgs) {
  const formData = await request.formData()
  const intent = formData.get('intent')

  switch (intent) {
    case 'delete': {
      await db.post.delete({ where: { id: params.id } })
      return redirect('/posts')
    }

    case 'publish': {
      await db.post.update({
        where: { id: params.id },
        data: { published: true }
      })
      return json({ success: true })
    }

    case 'unpublish': {
      await db.post.update({
        where: { id: params.id },
        data: { published: false }
      })
      return json({ success: true })
    }

    case 'add-comment': {
      const content = formData.get('content')
      await db.comment.create({
        data: {
          content: content as string,
          postId: params.id!,
          authorId: user.id
        }
      })
      return json({ success: true })
    }

    default:
      throw new Response('Invalid intent', { status: 400 })
  }
}

export default function PostPage() {
  const { post } = useLoaderData<typeof loader>()

  return (
    <article>
      <h1>{post.title}</h1>

      <div className="actions">
        {/* KaĹĽdy form ma inny intent */}
        <Form method="post">
          <input type="hidden" name="intent" value="delete" />
          <button type="submit">Delete</button>
        </Form>

        <Form method="post">
          <input
            type="hidden"
            name="intent"
            value={post.published ? 'unpublish' : 'publish'}
          />
          <button type="submit">
            {post.published ? 'Unpublish' : 'Publish'}
          </button>
        </Form>
      </div>

      {/* Form do komentarzy */}
      <Form method="post">
        <input type="hidden" name="intent" value="add-comment" />
        <textarea name="content" placeholder="Add a comment..." />
        <button type="submit">Comment</button>
      </Form>
    </article>
  )
}

useFetcher - Actions bez nawigacji

TSapp/routes/posts.$id.tsx
TypeScript
// app/routes/posts.$id.tsx
import { useFetcher } from '@remix-run/react'

function LikeButton({ postId, liked, likesCount }) {
  const fetcher = useFetcher()

  // Optimistic UI - zakładamy sukces
  const optimisticLiked = fetcher.formData
    ? fetcher.formData.get('intent') === 'like'
    : liked

  const optimisticCount = fetcher.formData
    ? likesCount + (fetcher.formData.get('intent') === 'like' ? 1 : -1)
    : likesCount

  return (
    <fetcher.Form method="post" action={`/posts/${postId}`}>
      <input
        type="hidden"
        name="intent"
        value={optimisticLiked ? 'unlike' : 'like'}
      />
      <button
        type="submit"
        disabled={fetcher.state !== 'idle'}
        className={optimisticLiked ? 'liked' : ''}
      >
        {optimisticLiked ? '❤️' : '🤍'} {optimisticCount}
      </button>
    </fetcher.Form>
  )
}

// Newsletter signup z fetcher
function NewsletterSignup() {
  const fetcher = useFetcher()

  const isSubmitting = fetcher.state === 'submitting'
  const isSuccess = fetcher.data?.success
  const error = fetcher.data?.error

  if (isSuccess) {
    return <p>Thanks for subscribing!</p>
  }

  return (
    <fetcher.Form method="post" action="/api/newsletter">
      <input
        type="email"
        name="email"
        placeholder="Enter your email"
        required
      />
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Subscribing...' : 'Subscribe'}
      </button>
      {error && <p className="error">{error}</p>}
    </fetcher.Form>
  )
}

Progressive Enhancement

Formularze działające bez JavaScript

TSapp/routes/contact.tsx
TypeScript
// app/routes/contact.tsx
import { Form, useNavigation } from '@remix-run/react'

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData()

  await sendEmail({
    from: formData.get('email') as string,
    subject: formData.get('subject') as string,
    message: formData.get('message') as string,
  })

  return json({ success: true })
}

export default function Contact() {
  const navigation = useNavigation()
  const actionData = useActionData<typeof action>()

  const isSubmitting = navigation.state === 'submitting'

  if (actionData?.success) {
    return (
      <div className="success">
        <h2>Message sent!</h2>
        <p>We'll get back to you soon.</p>
        <Link to="/">Back to home</Link>
      </div>
    )
  }

  return (
    <div>
      <h1>Contact Us</h1>

      {/* Ten formularz działa bez JavaScript! */}
      <Form method="post">
        <div>
          <label htmlFor="email">Email</label>
          <input type="email" id="email" name="email" required />
        </div>

        <div>
          <label htmlFor="subject">Subject</label>
          <input type="text" id="subject" name="subject" required />
        </div>

        <div>
          <label htmlFor="message">Message</label>
          <textarea id="message" name="message" rows={5} required />
        </div>

        <button type="submit" disabled={isSubmitting}>
          {/* Bez JS pokazuje "Send", z JS pokazuje loading state */}
          {isSubmitting ? 'Sending...' : 'Send Message'}
        </button>
      </Form>
    </div>
  )
}

Linki z prefetching

Code
TypeScript
import { Link, NavLink } from '@remix-run/react'

function Navigation() {
  return (
    <nav>
      {/* Prefetch on hover - ładuje dane przed kliknięciem */}
      <Link to="/posts" prefetch="intent">
        Blog
      </Link>

      {/* Prefetch od razu przy renderze */}
      <Link to="/about" prefetch="render">
        About
      </Link>

      {/* NavLink z active state */}
      <NavLink
        to="/dashboard"
        className={({ isActive, isPending }) =>
          isActive ? 'active' : isPending ? 'pending' : ''
        }
      >
        Dashboard
      </NavLink>
    </nav>
  )
}

Error Handling

Error Boundaries

TSapp/root.tsx
TypeScript
// app/root.tsx
import {
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
  isRouteErrorResponse,
  useRouteError
} from '@remix-run/react'

export function ErrorBoundary() {
  const error = useRouteError()

  if (isRouteErrorResponse(error)) {
    return (
      <html>
        <head>
          <title>{error.status} {error.statusText}</title>
          <Meta />
          <Links />
        </head>
        <body>
          <div className="error-page">
            <h1>{error.status}</h1>
            <p>{error.statusText}</p>
            {error.status === 404 && (
              <p>The page you're looking for doesn't exist.</p>
            )}
            <Link to="/">Go home</Link>
          </div>
          <Scripts />
        </body>
      </html>
    )
  }

  // Unknown error
  return (
    <html>
      <head>
        <title>Error</title>
        <Meta />
        <Links />
      </head>
      <body>
        <div className="error-page">
          <h1>Something went wrong</h1>
          <p>We're sorry, an unexpected error occurred.</p>
          <Link to="/">Go home</Link>
        </div>
        <Scripts />
      </body>
    </html>
  )
}
TSapp/routes/posts.$id.tsx
TypeScript
// app/routes/posts.$id.tsx - Error boundary dla konkretnego route
export function ErrorBoundary() {
  const error = useRouteError()

  if (isRouteErrorResponse(error) && error.status === 404) {
    return (
      <div className="not-found">
        <h1>Post not found</h1>
        <p>This post may have been deleted or doesn't exist.</p>
        <Link to="/posts">Browse all posts</Link>
      </div>
    )
  }

  return (
    <div className="error">
      <h1>Error loading post</h1>
      <p>Something went wrong. Please try again later.</p>
    </div>
  )
}

Throwing Responses

Code
TypeScript
export async function loader({ params }: LoaderFunctionArgs) {
  const post = await db.post.findUnique({ where: { id: params.id } })

  // 404 - Post nie istnieje
  if (!post) {
    throw new Response('Not Found', {
      status: 404,
      statusText: 'Post not found'
    })
  }

  // 403 - Brak uprawnień
  if (!post.published && !isAuthor) {
    throw new Response('Forbidden', {
      status: 403,
      statusText: 'You do not have access to this post'
    })
  }

  // 410 - Usunięty
  if (post.deletedAt) {
    throw new Response('Gone', {
      status: 410,
      statusText: 'This post has been deleted'
    })
  }

  return json({ post })
}

Meta Tags i SEO

Meta function

TSapp/routes/posts.$id.tsx
TypeScript
// app/routes/posts.$id.tsx
import type { MetaFunction } from '@remix-run/node'

export const meta: MetaFunction<typeof loader> = ({ data, params }) => {
  if (!data?.post) {
    return [
      { title: 'Post not found' },
      { name: 'robots', content: 'noindex' }
    ]
  }

  const { post } = data

  return [
    { title: `${post.title} | My Blog` },
    { name: 'description', content: post.excerpt },

    // Open Graph
    { property: 'og:title', content: post.title },
    { property: 'og:description', content: post.excerpt },
    { property: 'og:image', content: post.coverImage },
    { property: 'og:type', content: 'article' },
    { property: 'article:published_time', content: post.createdAt },
    { property: 'article:author', content: post.author.name },

    // Twitter
    { name: 'twitter:card', content: 'summary_large_image' },
    { name: 'twitter:title', content: post.title },
    { name: 'twitter:description', content: post.excerpt },
    { name: 'twitter:image', content: post.coverImage },

    // Canonical
    { tagName: 'link', rel: 'canonical', href: `https://myblog.com/posts/${params.id}` },
  ]
}

Headers function

TSapp/routes/posts.$id.tsx
TypeScript
// app/routes/posts.$id.tsx
import type { HeadersFunction } from '@remix-run/node'

export const headers: HeadersFunction = ({ loaderHeaders }) => ({
  'Cache-Control': loaderHeaders.get('Cache-Control') || 'max-age=300, s-maxage=3600',
  'X-Frame-Options': 'DENY',
})

// W loaderze ustaw headers
export async function loader({ params }: LoaderFunctionArgs) {
  const post = await db.post.findUnique({ where: { id: params.id } })

  return json({ post }, {
    headers: {
      'Cache-Control': 'public, max-age=60, s-maxage=600',
    }
  })
}

Resource Routes (API Endpoints)

JSON API

TSapp/routes/api.posts.ts
TypeScript
// app/routes/api.posts.ts (bez default export = resource route)
import type { LoaderFunctionArgs, ActionFunctionArgs } from '@remix-run/node'
import { json } from '@remix-run/node'

// GET /api/posts
export async function loader({ request }: LoaderFunctionArgs) {
  const url = new URL(request.url)
  const page = parseInt(url.searchParams.get('page') || '1')

  const posts = await db.post.findMany({
    skip: (page - 1) * 10,
    take: 10,
  })

  return json({ posts })
}

// POST /api/posts
export async function action({ request }: ActionFunctionArgs) {
  if (request.method !== 'POST') {
    return json({ error: 'Method not allowed' }, { status: 405 })
  }

  const body = await request.json()

  const post = await db.post.create({
    data: body
  })

  return json({ post }, { status: 201 })
}

File Downloads

TSapp/routes/files.$id.download.ts
TypeScript
// app/routes/files.$id.download.ts
export async function loader({ params }: LoaderFunctionArgs) {
  const file = await db.file.findUnique({ where: { id: params.id } })

  if (!file) {
    throw new Response('Not found', { status: 404 })
  }

  const fileContent = await storage.download(file.path)

  return new Response(fileContent, {
    headers: {
      'Content-Type': file.mimeType,
      'Content-Disposition': `attachment; filename="${file.name}"`,
      'Content-Length': file.size.toString(),
    }
  })
}

Webhooks

TSapp/routes/webhooks.stripe.ts
TypeScript
// app/routes/webhooks.stripe.ts
import type { ActionFunctionArgs } from '@remix-run/node'
import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export async function action({ request }: ActionFunctionArgs) {
  const payload = await request.text()
  const signature = request.headers.get('stripe-signature')!

  let event: Stripe.Event

  try {
    event = stripe.webhooks.constructEvent(
      payload,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch (err) {
    return new Response('Invalid signature', { status: 400 })
  }

  switch (event.type) {
    case 'checkout.session.completed':
      await handleCheckoutComplete(event.data.object)
      break
    case 'customer.subscription.updated':
      await handleSubscriptionUpdate(event.data.object)
      break
  }

  return new Response('OK', { status: 200 })
}

Integracja z bazami danych

Prisma

TSapp/utils/db.server.ts
TypeScript
// app/utils/db.server.ts
import { PrismaClient } from '@prisma/client'

let db: PrismaClient

declare global {
  var __db__: PrismaClient
}

// Unikaj tworzenia wielu instancji w dev mode
if (process.env.NODE_ENV === 'production') {
  db = new PrismaClient()
} else {
  if (!global.__db__) {
    global.__db__ = new PrismaClient()
  }
  db = global.__db__
}

export { db }
TSapp/routes/posts._index.tsx
TypeScript
// app/routes/posts._index.tsx
import { db } from '~/utils/db.server'

export async function loader() {
  const posts = await db.post.findMany({
    where: { published: true },
    include: { author: true },
    orderBy: { createdAt: 'desc' }
  })

  return json({ posts })
}

Drizzle ORM

TSapp/utils/db.server.ts
TypeScript
// app/utils/db.server.ts
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import * as schema from './schema'

const client = postgres(process.env.DATABASE_URL!)
export const db = drizzle(client, { schema })

// app/routes/posts._index.tsx
import { db } from '~/utils/db.server'
import { posts, users } from '~/utils/schema'
import { eq, desc } from 'drizzle-orm'

export async function loader() {
  const allPosts = await db
    .select()
    .from(posts)
    .leftJoin(users, eq(posts.authorId, users.id))
    .where(eq(posts.published, true))
    .orderBy(desc(posts.createdAt))

  return json({ posts: allPosts })
}

Autentykacja

Cookie-based Auth

TSapp/utils/session.server.ts
TypeScript
// app/utils/session.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',
    maxAge: 60 * 60 * 24 * 30, // 30 days
  },
})

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) {
  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
}

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),
    },
  })
}

// Login action
export async function login(email: string, password: string) {
  const user = await db.user.findUnique({ where: { email } })

  if (!user) return null

  const isValid = await bcrypt.compare(password, user.passwordHash)

  if (!isValid) return null

  return user
}
TSapp/routes/login.tsx
TypeScript
// app/routes/login.tsx
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 user = await login(email, password)

  if (!user) {
    return json({ error: 'Invalid credentials' }, { status: 400 })
  }

  return createUserSession(user.id, '/dashboard')
}

OAuth (z remix-auth)

TSapp/utils/auth.server.ts
TypeScript
// app/utils/auth.server.ts
import { Authenticator } from 'remix-auth'
import { GitHubStrategy } from 'remix-auth-github'
import { sessionStorage } from './session.server'

export const authenticator = new Authenticator(sessionStorage)

authenticator.use(
  new GitHubStrategy(
    {
      clientID: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
      callbackURL: 'http://localhost:3000/auth/github/callback',
    },
    async ({ profile }) => {
      // ZnajdĹş lub utwĂłrz uĹĽytkownika
      let user = await db.user.findUnique({
        where: { githubId: profile.id },
      })

      if (!user) {
        user = await db.user.create({
          data: {
            githubId: profile.id,
            email: profile.emails[0].value,
            name: profile.displayName,
            avatar: profile.photos[0].value,
          },
        })
      }

      return user
    }
  ),
  'github'
)

// app/routes/auth.github.tsx
export async function action({ request }: ActionFunctionArgs) {
  return authenticator.authenticate('github', request)
}

// app/routes/auth.github.callback.tsx
export async function loader({ request }: LoaderFunctionArgs) {
  return authenticator.authenticate('github', request, {
    successRedirect: '/dashboard',
    failureRedirect: '/login',
  })
}

Deployment

Vercel

JSremix.config.js
JavaScript
// remix.config.js
module.exports = {
  serverBuildTarget: "vercel",
  server: process.env.NODE_ENV === "development" ? undefined : "./server.js",
}
vercel.json
JSON
// vercel.json
{
  "buildCommand": "remix build",
  "devCommand": "remix dev",
  "installCommand": "npm install",
  "framework": "remix"
}

Fly.io

Code
DOCKERFILE
# Dockerfile
FROM node:20-slim AS base

FROM base AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci

FROM base AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM base AS production
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/build ./build
COPY --from=build /app/public ./public
COPY --from=build /app/package.json ./
EXPOSE 3000
CMD ["npm", "start"]
fly.toml
TOML
# fly.toml
app = "my-remix-app"
primary_region = "waw"

[build]
  dockerfile = "Dockerfile"

[http_service]
  internal_port = 3000
  force_https = true

[env]
  NODE_ENV = "production"

Cloudflare Workers

TSserver.ts
TypeScript
// server.ts
import { createRequestHandler, createCookieSessionStorage } from "@remix-run/cloudflare"
import * as build from "@remix-run/dev/server-build"

const handleRequest = createRequestHandler(build, process.env.NODE_ENV)

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext) {
    return handleRequest(request, {
      env,
      waitUntil: ctx.waitUntil.bind(ctx),
    })
  },
}

Testing

Unit tests z Vitest

TSapp/utils/validators.test.ts
TypeScript
// app/utils/validators.test.ts
import { describe, it, expect } from 'vitest'
import { validateEmail, validatePassword } from './validators'

describe('validateEmail', () => {
  it('returns error for invalid email', () => {
    expect(validateEmail('invalid')).toBe('Invalid email address')
  })

  it('returns undefined for valid email', () => {
    expect(validateEmail('test@example.com')).toBeUndefined()
  })
})

Integration tests

TSapp/routes/posts.test.ts
TypeScript
// app/routes/posts.test.ts
import { createRemixStub } from '@remix-run/testing'
import { render, screen } from '@testing-library/react'
import PostsRoute, { loader } from './posts._index'

describe('Posts route', () => {
  it('renders posts list', async () => {
    const RemixStub = createRemixStub([
      {
        path: '/posts',
        Component: PostsRoute,
        loader: () => ({
          posts: [
            { id: '1', title: 'First Post' },
            { id: '2', title: 'Second Post' },
          ]
        }),
      },
    ])

    render(<RemixStub initialEntries={['/posts']} />)

    expect(await screen.findByText('First Post')).toBeInTheDocument()
    expect(await screen.findByText('Second Post')).toBeInTheDocument()
  })
})

E2E tests z Playwright

TSe2e/posts.spec.ts
TypeScript
// e2e/posts.spec.ts
import { test, expect } from '@playwright/test'

test.describe('Blog posts', () => {
  test('can create a new post', async ({ page }) => {
    await page.goto('/login')
    await page.fill('[name="email"]', 'test@example.com')
    await page.fill('[name="password"]', 'password123')
    await page.click('button[type="submit"]')

    await page.goto('/posts/new')
    await page.fill('[name="title"]', 'My Test Post')
    await page.fill('[name="content"]', 'This is the content of my test post.')
    await page.click('button[type="submit"]')

    await expect(page).toHaveURL(/\/posts\/[a-z0-9]+/)
    await expect(page.locator('h1')).toHaveText('My Test Post')
  })
})

Best Practices

Organizacja kodu

Code
TEXT
app/
├── components/
│   ├── ui/              # Generic UI components
│   │   ├── Button.tsx
│   │   ├── Input.tsx
│   │   └── Modal.tsx
│   └── features/        # Feature-specific components
│       ├── posts/
│       │   ├── PostCard.tsx
│       │   └── PostList.tsx
│       └── comments/
│           └── CommentForm.tsx
├── routes/
├── utils/
│   ├── db.server.ts     # Database client
│   ├── session.server.ts # Session management
│   └── validators.ts    # Form validation
├── models/              # Business logic
│   ├── post.server.ts
│   └── user.server.ts
└── styles/

Separation of concerns

TSapp/models/post.server.ts
TypeScript
// app/models/post.server.ts - Business logic
import { db } from '~/utils/db.server'

export async function getPosts(page: number, limit: number = 10) {
  return db.post.findMany({
    where: { published: true },
    orderBy: { createdAt: 'desc' },
    skip: (page - 1) * limit,
    take: limit,
    include: { author: true }
  })
}

export async function createPost(data: CreatePostInput, authorId: string) {
  return db.post.create({
    data: {
      ...data,
      authorId,
    }
  })
}

// app/routes/posts._index.tsx - Route uĹĽywa modelu
import { getPosts } from '~/models/post.server'

export async function loader({ request }: LoaderFunctionArgs) {
  const url = new URL(request.url)
  const page = parseInt(url.searchParams.get('page') || '1')

  const posts = await getPosts(page)

  return json({ posts })
}

FAQ - Najczęściej zadawane pytania

Czym Remix różni się od Next.js?

Remix skupia się na web standards i progressive enhancement. Formularze działają bez JS, dane są ładowane równolegle dla nested routes, a mutations używają natywnych form actions. Next.js oferuje więcej opcji (SSG, ISR, React Server Components) ale wymaga więcej JavaScript po stronie klienta.

Czy Remix jest gotowy do produkcji?

Tak. Remix jest używany przez duże firmy jak Shopify, NASA, Netflix. Po przejęciu przez Shopify ma solidne finansowanie i aktywny development.

Czy mogę użyć React Server Components w Remix?

Obecnie nie. Remix ma własne podejście do server-side data loading przez loadery. Team Remix monitoruje RSC ale priorytetem jest progressive enhancement.

Jak zaimplementować infinite scroll w Remix?

Użyj useFetcher do ładowania kolejnych stron bez pełnej nawigacji:

Code
TypeScript
function InfiniteList() {
  const { posts } = useLoaderData<typeof loader>()
  const fetcher = useFetcher()
  const [allPosts, setAllPosts] = useState(posts)

  useEffect(() => {
    if (fetcher.data?.posts) {
      setAllPosts(prev => [...prev, ...fetcher.data.posts])
    }
  }, [fetcher.data])

  const loadMore = () => {
    const page = Math.ceil(allPosts.length / 10) + 1
    fetcher.load(`/posts?page=${page}`)
  }

  return (
    <div>
      {allPosts.map(post => <PostCard key={post.id} {...post} />)}
      <button onClick={loadMore} disabled={fetcher.state === 'loading'}>
        Load More
      </button>
    </div>
  )
}

Jak obsługiwać file uploads?

Code
TypeScript
export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData()
  const file = formData.get('file') as File

  const buffer = Buffer.from(await file.arrayBuffer())
  const filename = `${Date.now()}-${file.name}`

  // Upload do S3, Cloudinary, etc.
  await uploadToStorage(buffer, filename)

  return json({ success: true, filename })
}

Podsumowanie

Remix to framework dla deweloperów, którzy chcą wykorzystać potencjał web platform zamiast walczyć z jej ograniczeniami. Kluczowe cechy:

  • Progressive Enhancement - Aplikacje dziaĹ‚ajÄ… bez JavaScript i ulepszajÄ… siÄ™ gdy JS jest dostÄ™pny
  • Nested Routes - Hierarchiczna struktura z rĂłwnolegĹ‚ym Ĺ‚adowaniem danych
  • Web Standards - HTTP, formularze HTML, cookies dziaĹ‚ajÄ… natywnie
  • Full Type Safety - PeĹ‚na integracja z TypeScript
  • Error Boundaries - Granularne error handling na poziomie kaĹĽdego route'a

Remix jest idealny do budowania aplikacji, gdzie dostępność, SEO i performance są priorytetami.


Remix - fullstack web framework built on web standards

What is Remix?

Remix is a modern fullstack React framework created by the makers of React Router (Michael Jackson and Ryan Florence). Unlike other React frameworks, Remix focuses on leveraging native browser mechanisms and web standards instead of inventing its own abstractions. The Remix philosophy is: "use the platform" - take advantage of what the browser already knows how to do.

Remix was acquired by Shopify in 2022, which gave it solid funding and enterprise support. The framework is used by companies like NASA, Shopify, Netflix, and many others to build performant, accessible web applications.

The key difference between Remix and other frameworks lies in its approach to data loading and mutations - instead of client-side state management, Remix encourages using the server to manage application state. Forms work natively without JavaScript, and the entire application is progressively enhanced.

Why Remix?

Key framework advantages

  1. Web Standards First - HTTP, HTML forms, and cookies work the way they should
  2. Progressive Enhancement - The application works without JavaScript and improves when JS is available
  3. Nested Routes - Hierarchical route structure with automatic layout inheritance
  4. Data Loading - Loaders fetch data in parallel for all nested routes
  5. Mutations - Actions handle forms without client-side state management
  6. Error Boundaries - Granular error handling at the level of each route
  7. SEO Friendly - Full SSR with control over meta tags and headers
  8. Type Safety - Full TypeScript integration

Remix vs Next.js vs Astro

FeatureRemixNext.jsAstro
RoutingNested, file-basedFile-based, App RouterFile-based
Data LoadingLoaders (server)Server Components, fetchAstro.props
MutationsActions + FormsServer ActionsAPI endpoints
Progressive EnhancementNativeRequires JSPartial
StreamingYesYesYes
Edge RuntimeYesYesYes
Static ExportPartialYesYes
React Server ComponentsNoYesNo
Learning CurveMediumHighLow

When to choose Remix?

Remix is ideal when:

  • You are building applications with lots of forms and interactions
  • You need progressive enhancement (accessibility!)
  • You want to leverage web standards instead of abstractions
  • You have a complex hierarchy of layouts
  • SEO and performance matter to you
  • You want to avoid client-side state management

Consider alternatives when:

  • You need full static site generation (Astro, Next.js)
  • You prefer React Server Components (Next.js)
  • You are building a simple page without interactions (Astro)

Installation and configuration

Creating a new project

Code
Bash
npx create-remix@latest my-remix-app

npx create-remix@latest --template remix-run/indie-stack my-app
npx create-remix@latest --template remix-run/blues-stack my-app
npx create-remix@latest --template remix-run/grunge-stack my-app

npx create-remix@latest --template remix-run/remix/templates/remix

Available official templates (Stacks)

Code
Bash
# Indie Stack - SQLite, Tailwind, Fly.io
# Perfect for small projects and prototypes
npx create-remix@latest --template remix-run/indie-stack

# Blues Stack - PostgreSQL, Docker, Fly.io
# For larger production applications
npx create-remix@latest --template remix-run/blues-stack

# Grunge Stack - DynamoDB, AWS Lambda
# For serverless on AWS
npx create-remix@latest --template remix-run/grunge-stack

Project structure

Code
TEXT
my-remix-app/
├── app/
│   ├── routes/                 # File-based routing
│   │   ├── _index.tsx         # /
│   │   ├── about.tsx          # /about
│   │   ├── posts._index.tsx   # /posts
│   │   ├── posts.$id.tsx      # /posts/:id
│   │   └── posts.new.tsx      # /posts/new
│   ├── components/            # React components
│   ├── utils/                 # Utility functions
│   ├── styles/                # CSS files
│   ├── entry.client.tsx       # Client entry point
│   ├── entry.server.tsx       # Server entry point
│   └── root.tsx               # Root layout
├── public/                    # Static assets
├── remix.config.js            # Remix configuration
├── package.json
└── tsconfig.json

Remix configuration

JSremix.config.js
JavaScript
// remix.config.js
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
  ignoredRouteFiles: ["**/.*"],

  serverModuleFormat: "esm",

  future: {
    v2_dev: true,
    v2_errorBoundary: true,
    v2_headers: true,
    v2_meta: true,
    v2_normalizeFormMethod: true,
    v2_routeConvention: true,
  },

  tailwind: true,
  postcss: true,

  serverBuildTarget: "vercel",
  server: process.env.NODE_ENV === "development" ? undefined : "./server.js",
}

Routing in Remix

File-based routing

Remix uses file naming conventions to define routes:

Code
TEXT
app/routes/
├── _index.tsx              # GET /
├── about.tsx               # GET /about
├── contact.tsx             # GET /contact
│
├── posts._index.tsx        # GET /posts
├── posts.$id.tsx           # GET /posts/:id (dynamic)
├── posts.new.tsx           # GET /posts/new
├── posts.$id.edit.tsx      # GET /posts/:id/edit
│
├── blog_.tsx               # Layout for /blog/* without nested UI
├── blog_.$slug.tsx         # GET /blog/:slug (flat route)
│
├── ($lang).about.tsx       # GET /about or GET /pl/about (optional)
├── files.$.tsx             # GET /files/* (splat/catch-all)
│
├── _auth.tsx               # Layout without URL segment
├── _auth.login.tsx         # GET /login
├── _auth.register.tsx      # GET /register
│
├── api.users.ts            # GET/POST /api/users (resource route)
└── healthcheck.ts          # GET /healthcheck

Naming conventions

PatternExample fileURLDescription
_index_index.tsx/Index route
nameabout.tsx/aboutStatic route
$paramposts.$id.tsx/posts/123Dynamic segment
. (dot)posts.new.tsx/posts/newNested route
_ (prefix)_auth.login.tsx/loginPathless layout
_ (suffix)blog_.tsx-Layout escape
($param)($lang).about.tsx/about or /pl/aboutOptional segment
$files.$.tsx/files/*Splat route

Nested routes and layouts

TSapp/routes/dashboard.tsx
TypeScript
// app/routes/dashboard.tsx - Parent layout
import { Outlet } from '@remix-run/react'

export default function DashboardLayout() {
  return (
    <div className="dashboard">
      <nav className="sidebar">
        <NavLink to="/dashboard">Overview</NavLink>
        <NavLink to="/dashboard/analytics">Analytics</NavLink>
        <NavLink to="/dashboard/settings">Settings</NavLink>
      </nav>

      <main className="content">
        <Outlet />
      </main>
    </div>
  )
}
TSapp/routes/dashboard._index.tsx
TypeScript
// app/routes/dashboard._index.tsx - /dashboard
export default function DashboardIndex() {
  return <h1>Dashboard Overview</h1>
}

// app/routes/dashboard.analytics.tsx - /dashboard/analytics
export default function DashboardAnalytics() {
  return <h1>Analytics</h1>
}

// app/routes/dashboard.settings.tsx - /dashboard/settings
export default function DashboardSettings() {
  return <h1>Settings</h1>
}

Pathless layouts (grouping)

TSapp/routes/_auth.tsx
TypeScript
// app/routes/_auth.tsx - Layout for auth pages without /auth in the URL
import { Outlet } from '@remix-run/react'

export default function AuthLayout() {
  return (
    <div className="auth-container">
      <div className="auth-card">
        <img src="/logo.svg" alt="Logo" />
        <Outlet />
      </div>
    </div>
  )
}

// app/routes/_auth.login.tsx -> /login
// app/routes/_auth.register.tsx -> /register
// app/routes/_auth.forgot-password.tsx -> /forgot-password

Loaders - server-side data loading

Basic loader

TSapp/routes/posts._index.tsx
TypeScript
// app/routes/posts._index.tsx
import type { LoaderFunctionArgs } from '@remix-run/node'
import { json } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'
import { db } from '~/utils/db.server'

export async function loader({ request }: LoaderFunctionArgs) {
  const url = new URL(request.url)
  const page = parseInt(url.searchParams.get('page') || '1')
  const limit = 10

  const posts = await db.post.findMany({
    where: { published: true },
    orderBy: { createdAt: 'desc' },
    skip: (page - 1) * limit,
    take: limit,
    include: {
      author: {
        select: { name: true, avatar: true }
      }
    }
  })

  const total = await db.post.count({ where: { published: true } })

  return json({
    posts,
    pagination: {
      page,
      totalPages: Math.ceil(total / limit),
      total
    }
  })
}

export default function PostsIndex() {
  const { posts, pagination } = useLoaderData<typeof loader>()

  return (
    <div>
      <h1>Blog Posts</h1>

      <ul>
        {posts.map(post => (
          <li key={post.id}>
            <Link to={`/posts/${post.id}`}>
              <h2>{post.title}</h2>
              <p>By {post.author.name}</p>
            </Link>
          </li>
        ))}
      </ul>

      <Pagination {...pagination} />
    </div>
  )
}

Dynamic routes with parameters

TSapp/routes/posts.$id.tsx
TypeScript
// app/routes/posts.$id.tsx
import type { LoaderFunctionArgs } from '@remix-run/node'
import { json } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'
import { db } from '~/utils/db.server'

export async function loader({ params }: LoaderFunctionArgs) {
  const post = await db.post.findUnique({
    where: { id: params.id },
    include: {
      author: true,
      comments: {
        include: { author: true },
        orderBy: { createdAt: 'desc' }
      }
    }
  })

  if (!post) {
    throw new Response('Post not found', { status: 404 })
  }

  return json({ post })
}

export default function PostPage() {
  const { post } = useLoaderData<typeof loader>()

  return (
    <article>
      <h1>{post.title}</h1>
      <div className="meta">
        <span>By {post.author.name}</span>
        <time>{new Date(post.createdAt).toLocaleDateString()}</time>
      </div>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />

      <section className="comments">
        <h2>Comments ({post.comments.length})</h2>
        {post.comments.map(comment => (
          <Comment key={comment.id} {...comment} />
        ))}
      </section>
    </article>
  )
}

Parallel data loading

Remix automatically loads data for all nested routes in parallel:

TSapp/routes/dashboard.tsx
TypeScript
// app/routes/dashboard.tsx
export async function loader({ request }: LoaderFunctionArgs) {
  const user = await requireUser(request)
  return json({ user })
}

// app/routes/dashboard.analytics.tsx
export async function loader({ request }: LoaderFunctionArgs) {
  const analytics = await getAnalytics()
  return json({ analytics })
}

Both loaders execute at the same time. The Dashboard Layout receives the user, while the Analytics Page receives the analytics data.

Loader with authentication

TSapp/utils/session.server.ts
TypeScript
// 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 getUser(request: Request) {
  const session = await sessionStorage.getSession(
    request.headers.get('Cookie')
  )
  const userId = session.get('userId')

  if (!userId) return null

  return db.user.findUnique({ where: { id: userId } })
}

export async function requireUser(request: Request) {
  const user = await getUser(request)

  if (!user) {
    throw redirect('/login')
  }

  return user
}

// app/routes/dashboard.tsx
export async function loader({ request }: LoaderFunctionArgs) {
  const user = await requireUser(request)

  const [stats, notifications] = await Promise.all([
    getStats(user.id),
    getNotifications(user.id)
  ])

  return json({ user, stats, notifications })
}

Actions - form handling and mutations

Basic action

TSapp/routes/posts.new.tsx
TypeScript
// app/routes/posts.new.tsx
import type { ActionFunctionArgs } from '@remix-run/node'
import { redirect, json } from '@remix-run/node'
import { Form, useActionData } from '@remix-run/react'
import { requireUser } from '~/utils/session.server'
import { db } from '~/utils/db.server'

export async function action({ request }: ActionFunctionArgs) {
  const user = await requireUser(request)

  const formData = await request.formData()
  const title = formData.get('title')
  const content = formData.get('content')
  const published = formData.get('published') === 'on'

  const errors: Record<string, string> = {}

  if (!title || typeof title !== 'string' || title.length < 3) {
    errors.title = 'Title must be at least 3 characters'
  }

  if (!content || typeof content !== 'string' || content.length < 10) {
    errors.content = 'Content must be at least 10 characters'
  }

  if (Object.keys(errors).length > 0) {
    return json({ errors }, { status: 400 })
  }

  const post = await db.post.create({
    data: {
      title: title as string,
      content: content as string,
      published,
      authorId: user.id,
    },
  })

  return redirect(`/posts/${post.id}`)
}

export default function NewPost() {
  const actionData = useActionData<typeof action>()

  return (
    <div>
      <h1>Create New Post</h1>

      <Form method="post">
        <div>
          <label htmlFor="title">Title</label>
          <input
            type="text"
            id="title"
            name="title"
            required
          />
          {actionData?.errors?.title && (
            <p className="error">{actionData.errors.title}</p>
          )}
        </div>

        <div>
          <label htmlFor="content">Content</label>
          <textarea
            id="content"
            name="content"
            rows={10}
            required
          />
          {actionData?.errors?.content && (
            <p className="error">{actionData.errors.content}</p>
          )}
        </div>

        <div>
          <label>
            <input type="checkbox" name="published" />
            Publish immediately
          </label>
        </div>

        <button type="submit">Create Post</button>
      </Form>
    </div>
  )
}

Multiple actions in a single route

TSapp/routes/posts.$id.tsx
TypeScript
// app/routes/posts.$id.tsx
import { Form } from '@remix-run/react'

export async function action({ request, params }: ActionFunctionArgs) {
  const formData = await request.formData()
  const intent = formData.get('intent')

  switch (intent) {
    case 'delete': {
      await db.post.delete({ where: { id: params.id } })
      return redirect('/posts')
    }

    case 'publish': {
      await db.post.update({
        where: { id: params.id },
        data: { published: true }
      })
      return json({ success: true })
    }

    case 'unpublish': {
      await db.post.update({
        where: { id: params.id },
        data: { published: false }
      })
      return json({ success: true })
    }

    case 'add-comment': {
      const content = formData.get('content')
      await db.comment.create({
        data: {
          content: content as string,
          postId: params.id!,
          authorId: user.id
        }
      })
      return json({ success: true })
    }

    default:
      throw new Response('Invalid intent', { status: 400 })
  }
}

export default function PostPage() {
  const { post } = useLoaderData<typeof loader>()

  return (
    <article>
      <h1>{post.title}</h1>

      <div className="actions">
        <Form method="post">
          <input type="hidden" name="intent" value="delete" />
          <button type="submit">Delete</button>
        </Form>

        <Form method="post">
          <input
            type="hidden"
            name="intent"
            value={post.published ? 'unpublish' : 'publish'}
          />
          <button type="submit">
            {post.published ? 'Unpublish' : 'Publish'}
          </button>
        </Form>
      </div>

      <Form method="post">
        <input type="hidden" name="intent" value="add-comment" />
        <textarea name="content" placeholder="Add a comment..." />
        <button type="submit">Comment</button>
      </Form>
    </article>
  )
}

useFetcher - actions without navigation

TSapp/routes/posts.$id.tsx
TypeScript
// app/routes/posts.$id.tsx
import { useFetcher } from '@remix-run/react'

function LikeButton({ postId, liked, likesCount }) {
  const fetcher = useFetcher()

  const optimisticLiked = fetcher.formData
    ? fetcher.formData.get('intent') === 'like'
    : liked

  const optimisticCount = fetcher.formData
    ? likesCount + (fetcher.formData.get('intent') === 'like' ? 1 : -1)
    : likesCount

  return (
    <fetcher.Form method="post" action={`/posts/${postId}`}>
      <input
        type="hidden"
        name="intent"
        value={optimisticLiked ? 'unlike' : 'like'}
      />
      <button
        type="submit"
        disabled={fetcher.state !== 'idle'}
        className={optimisticLiked ? 'liked' : ''}
      >
        {optimisticLiked ? '❤️' : '🤍'} {optimisticCount}
      </button>
    </fetcher.Form>
  )
}

function NewsletterSignup() {
  const fetcher = useFetcher()

  const isSubmitting = fetcher.state === 'submitting'
  const isSuccess = fetcher.data?.success
  const error = fetcher.data?.error

  if (isSuccess) {
    return <p>Thanks for subscribing!</p>
  }

  return (
    <fetcher.Form method="post" action="/api/newsletter">
      <input
        type="email"
        name="email"
        placeholder="Enter your email"
        required
      />
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Subscribing...' : 'Subscribe'}
      </button>
      {error && <p className="error">{error}</p>}
    </fetcher.Form>
  )
}

Progressive enhancement

Forms that work without JavaScript

TSapp/routes/contact.tsx
TypeScript
// app/routes/contact.tsx
import { Form, useNavigation } from '@remix-run/react'

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData()

  await sendEmail({
    from: formData.get('email') as string,
    subject: formData.get('subject') as string,
    message: formData.get('message') as string,
  })

  return json({ success: true })
}

export default function Contact() {
  const navigation = useNavigation()
  const actionData = useActionData<typeof action>()

  const isSubmitting = navigation.state === 'submitting'

  if (actionData?.success) {
    return (
      <div className="success">
        <h2>Message sent!</h2>
        <p>We'll get back to you soon.</p>
        <Link to="/">Back to home</Link>
      </div>
    )
  }

  return (
    <div>
      <h1>Contact Us</h1>

      <Form method="post">
        <div>
          <label htmlFor="email">Email</label>
          <input type="email" id="email" name="email" required />
        </div>

        <div>
          <label htmlFor="subject">Subject</label>
          <input type="text" id="subject" name="subject" required />
        </div>

        <div>
          <label htmlFor="message">Message</label>
          <textarea id="message" name="message" rows={5} required />
        </div>

        <button type="submit" disabled={isSubmitting}>
          {isSubmitting ? 'Sending...' : 'Send Message'}
        </button>
      </Form>
    </div>
  )
}

Links with prefetching

Code
TypeScript
import { Link, NavLink } from '@remix-run/react'

function Navigation() {
  return (
    <nav>
      <Link to="/posts" prefetch="intent">
        Blog
      </Link>

      <Link to="/about" prefetch="render">
        About
      </Link>

      <NavLink
        to="/dashboard"
        className={({ isActive, isPending }) =>
          isActive ? 'active' : isPending ? 'pending' : ''
        }
      >
        Dashboard
      </NavLink>
    </nav>
  )
}

The prefetch="intent" setting loads data before the user clicks, triggering when the user hovers over or focuses the link. The prefetch="render" setting starts loading data immediately when the link is rendered on the page. NavLink provides automatic active state styling, making it easy to highlight the current page in navigation.

Error handling

Error Boundaries

TSapp/root.tsx
TypeScript
// app/root.tsx
import {
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
  isRouteErrorResponse,
  useRouteError
} from '@remix-run/react'

export function ErrorBoundary() {
  const error = useRouteError()

  if (isRouteErrorResponse(error)) {
    return (
      <html>
        <head>
          <title>{error.status} {error.statusText}</title>
          <Meta />
          <Links />
        </head>
        <body>
          <div className="error-page">
            <h1>{error.status}</h1>
            <p>{error.statusText}</p>
            {error.status === 404 && (
              <p>The page you're looking for doesn't exist.</p>
            )}
            <Link to="/">Go home</Link>
          </div>
          <Scripts />
        </body>
      </html>
    )
  }

  return (
    <html>
      <head>
        <title>Error</title>
        <Meta />
        <Links />
      </head>
      <body>
        <div className="error-page">
          <h1>Something went wrong</h1>
          <p>We're sorry, an unexpected error occurred.</p>
          <Link to="/">Go home</Link>
        </div>
        <Scripts />
      </body>
    </html>
  )
}
TSapp/routes/posts.$id.tsx
TypeScript
// app/routes/posts.$id.tsx - Error boundary for a specific route
export function ErrorBoundary() {
  const error = useRouteError()

  if (isRouteErrorResponse(error) && error.status === 404) {
    return (
      <div className="not-found">
        <h1>Post not found</h1>
        <p>This post may have been deleted or doesn't exist.</p>
        <Link to="/posts">Browse all posts</Link>
      </div>
    )
  }

  return (
    <div className="error">
      <h1>Error loading post</h1>
      <p>Something went wrong. Please try again later.</p>
    </div>
  )
}

Each route can define its own ErrorBoundary. When an error occurs in a specific route, only that route's ErrorBoundary renders - the rest of the application continues to work normally. This is a huge advantage over traditional approaches where a single error can crash the entire page.

Throwing Responses

Code
TypeScript
export async function loader({ params }: LoaderFunctionArgs) {
  const post = await db.post.findUnique({ where: { id: params.id } })

  if (!post) {
    throw new Response('Not Found', {
      status: 404,
      statusText: 'Post not found'
    })
  }

  if (!post.published && !isAuthor) {
    throw new Response('Forbidden', {
      status: 403,
      statusText: 'You do not have access to this post'
    })
  }

  if (post.deletedAt) {
    throw new Response('Gone', {
      status: 410,
      statusText: 'This post has been deleted'
    })
  }

  return json({ post })
}

Throwing a Response in a loader or action immediately stops execution and renders the nearest ErrorBoundary. The isRouteErrorResponse helper in the ErrorBoundary lets you differentiate between HTTP errors (like 404, 403, 410) and unexpected runtime errors, allowing you to display appropriate messages for each case.

Meta tags and SEO

Meta function

TSapp/routes/posts.$id.tsx
TypeScript
// app/routes/posts.$id.tsx
import type { MetaFunction } from '@remix-run/node'

export const meta: MetaFunction<typeof loader> = ({ data, params }) => {
  if (!data?.post) {
    return [
      { title: 'Post not found' },
      { name: 'robots', content: 'noindex' }
    ]
  }

  const { post } = data

  return [
    { title: `${post.title} | My Blog` },
    { name: 'description', content: post.excerpt },

    { property: 'og:title', content: post.title },
    { property: 'og:description', content: post.excerpt },
    { property: 'og:image', content: post.coverImage },
    { property: 'og:type', content: 'article' },
    { property: 'article:published_time', content: post.createdAt },
    { property: 'article:author', content: post.author.name },

    { name: 'twitter:card', content: 'summary_large_image' },
    { name: 'twitter:title', content: post.title },
    { name: 'twitter:description', content: post.excerpt },
    { name: 'twitter:image', content: post.coverImage },

    { tagName: 'link', rel: 'canonical', href: `https://myblog.com/posts/${params.id}` },
  ]
}

The meta function receives data from the loader with full type inference. This means you can dynamically generate meta tags based on the actual content being displayed. Each route can define its own meta function, and by default child routes replace parent meta tags entirely - which gives you full control over what appears in the <head> for each page.

Headers function

TSapp/routes/posts.$id.tsx
TypeScript
// app/routes/posts.$id.tsx
import type { HeadersFunction } from '@remix-run/node'

export const headers: HeadersFunction = ({ loaderHeaders }) => ({
  'Cache-Control': loaderHeaders.get('Cache-Control') || 'max-age=300, s-maxage=3600',
  'X-Frame-Options': 'DENY',
})

export async function loader({ params }: LoaderFunctionArgs) {
  const post = await db.post.findUnique({ where: { id: params.id } })

  return json({ post }, {
    headers: {
      'Cache-Control': 'public, max-age=60, s-maxage=600',
    }
  })
}

The headers function lets you control HTTP response headers for each route. You can set caching policies, security headers, and other HTTP headers. The function receives headers from the loader response, so you can forward or transform them as needed.

Resource routes (API endpoints)

JSON API

TSapp/routes/api.posts.ts
TypeScript
// app/routes/api.posts.ts (no default export = resource route)
import type { LoaderFunctionArgs, ActionFunctionArgs } from '@remix-run/node'
import { json } from '@remix-run/node'

// GET /api/posts
export async function loader({ request }: LoaderFunctionArgs) {
  const url = new URL(request.url)
  const page = parseInt(url.searchParams.get('page') || '1')

  const posts = await db.post.findMany({
    skip: (page - 1) * 10,
    take: 10,
  })

  return json({ posts })
}

// POST /api/posts
export async function action({ request }: ActionFunctionArgs) {
  if (request.method !== 'POST') {
    return json({ error: 'Method not allowed' }, { status: 405 })
  }

  const body = await request.json()

  const post = await db.post.create({
    data: body
  })

  return json({ post }, { status: 201 })
}

Resource routes are route files without a default component export. They only export a loader, action, or both. This makes them perfect for building JSON APIs, handling webhooks, generating sitemaps, or serving files - anything that does not need to render HTML.

File downloads

TSapp/routes/files.$id.download.ts
TypeScript
// app/routes/files.$id.download.ts
export async function loader({ params }: LoaderFunctionArgs) {
  const file = await db.file.findUnique({ where: { id: params.id } })

  if (!file) {
    throw new Response('Not found', { status: 404 })
  }

  const fileContent = await storage.download(file.path)

  return new Response(fileContent, {
    headers: {
      'Content-Type': file.mimeType,
      'Content-Disposition': `attachment; filename="${file.name}"`,
      'Content-Length': file.size.toString(),
    }
  })
}

Webhooks

TSapp/routes/webhooks.stripe.ts
TypeScript
// app/routes/webhooks.stripe.ts
import type { ActionFunctionArgs } from '@remix-run/node'
import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export async function action({ request }: ActionFunctionArgs) {
  const payload = await request.text()
  const signature = request.headers.get('stripe-signature')!

  let event: Stripe.Event

  try {
    event = stripe.webhooks.constructEvent(
      payload,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch (err) {
    return new Response('Invalid signature', { status: 400 })
  }

  switch (event.type) {
    case 'checkout.session.completed':
      await handleCheckoutComplete(event.data.object)
      break
    case 'customer.subscription.updated':
      await handleSubscriptionUpdate(event.data.object)
      break
  }

  return new Response('OK', { status: 200 })
}

Database integration

Prisma

TSapp/utils/db.server.ts
TypeScript
// app/utils/db.server.ts
import { PrismaClient } from '@prisma/client'

let db: PrismaClient

declare global {
  var __db__: PrismaClient
}

if (process.env.NODE_ENV === 'production') {
  db = new PrismaClient()
} else {
  if (!global.__db__) {
    global.__db__ = new PrismaClient()
  }
  db = global.__db__
}

export { db }

In development mode, Next.js and Remix restart the server module on every change, which would create a new PrismaClient instance each time. The global variable pattern above prevents this by reusing the existing client across hot reloads.

TSapp/routes/posts._index.tsx
TypeScript
// app/routes/posts._index.tsx
import { db } from '~/utils/db.server'

export async function loader() {
  const posts = await db.post.findMany({
    where: { published: true },
    include: { author: true },
    orderBy: { createdAt: 'desc' }
  })

  return json({ posts })
}

Drizzle ORM

TSapp/utils/db.server.ts
TypeScript
// app/utils/db.server.ts
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import * as schema from './schema'

const client = postgres(process.env.DATABASE_URL!)
export const db = drizzle(client, { schema })

// app/routes/posts._index.tsx
import { db } from '~/utils/db.server'
import { posts, users } from '~/utils/schema'
import { eq, desc } from 'drizzle-orm'

export async function loader() {
  const allPosts = await db
    .select()
    .from(posts)
    .leftJoin(users, eq(posts.authorId, users.id))
    .where(eq(posts.published, true))
    .orderBy(desc(posts.createdAt))

  return json({ posts: allPosts })
}

Drizzle ORM is a lightweight, type-safe alternative to Prisma. It generates SQL queries that closely mirror what you would write by hand, resulting in excellent performance. The query builder API is intuitive and provides full TypeScript inference from your schema definition.

Authentication

Cookie-based auth

TSapp/utils/session.server.ts
TypeScript
// app/utils/session.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',
    maxAge: 60 * 60 * 24 * 30,
  },
})

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) {
  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
}

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),
    },
  })
}

export async function login(email: string, password: string) {
  const user = await db.user.findUnique({ where: { email } })

  if (!user) return null

  const isValid = await bcrypt.compare(password, user.passwordHash)

  if (!isValid) return null

  return user
}

Remix provides built-in session management via createCookieSessionStorage. Sessions are stored in encrypted cookies, meaning there is no need for an external session store like Redis. The cookie is httpOnly (inaccessible from JavaScript), secure in production (sent only over HTTPS), and uses the sameSite lax policy to protect against CSRF attacks.

TSapp/routes/login.tsx
TypeScript
// app/routes/login.tsx
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 user = await login(email, password)

  if (!user) {
    return json({ error: 'Invalid credentials' }, { status: 400 })
  }

  return createUserSession(user.id, '/dashboard')
}

OAuth (with remix-auth)

TSapp/utils/auth.server.ts
TypeScript
// app/utils/auth.server.ts
import { Authenticator } from 'remix-auth'
import { GitHubStrategy } from 'remix-auth-github'
import { sessionStorage } from './session.server'

export const authenticator = new Authenticator(sessionStorage)

authenticator.use(
  new GitHubStrategy(
    {
      clientID: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
      callbackURL: 'http://localhost:3000/auth/github/callback',
    },
    async ({ profile }) => {
      let user = await db.user.findUnique({
        where: { githubId: profile.id },
      })

      if (!user) {
        user = await db.user.create({
          data: {
            githubId: profile.id,
            email: profile.emails[0].value,
            name: profile.displayName,
            avatar: profile.photos[0].value,
          },
        })
      }

      return user
    }
  ),
  'github'
)

// app/routes/auth.github.tsx
export async function action({ request }: ActionFunctionArgs) {
  return authenticator.authenticate('github', request)
}

// app/routes/auth.github.callback.tsx
export async function loader({ request }: LoaderFunctionArgs) {
  return authenticator.authenticate('github', request, {
    successRedirect: '/dashboard',
    failureRedirect: '/login',
  })
}

The remix-auth library follows the Passport.js strategy pattern. You can use multiple strategies simultaneously (GitHub, Google, Discord, email/password) and switch between them easily. Each strategy handles the OAuth flow, and the verify callback lets you find or create users in your database based on the provider profile.

Deployment

Vercel

JSremix.config.js
JavaScript
// remix.config.js
module.exports = {
  serverBuildTarget: "vercel",
  server: process.env.NODE_ENV === "development" ? undefined : "./server.js",
}
vercel.json
JSON
// vercel.json
{
  "buildCommand": "remix build",
  "devCommand": "remix dev",
  "installCommand": "npm install",
  "framework": "remix"
}

Deploying to Vercel is straightforward - just connect your repository and Vercel will automatically detect the Remix framework and configure the build settings. Remix routes are deployed as serverless functions, and static assets are served from the edge CDN.

Fly.io

Code
DOCKERFILE
# Dockerfile
FROM node:20-slim AS base

FROM base AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci

FROM base AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM base AS production
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/build ./build
COPY --from=build /app/public ./public
COPY --from=build /app/package.json ./
EXPOSE 3000
CMD ["npm", "start"]
fly.toml
TOML
# fly.toml
app = "my-remix-app"
primary_region = "waw"

[build]
  dockerfile = "Dockerfile"

[http_service]
  internal_port = 3000
  force_https = true

[env]
  NODE_ENV = "production"

Fly.io runs your Remix application as a long-lived server process in containers distributed across multiple regions. This is ideal for applications that need persistent connections, WebSockets, or low-latency access for users around the world. The multi-stage Dockerfile keeps the final image small by only including production dependencies and build artifacts.

Cloudflare Workers

TSserver.ts
TypeScript
// server.ts
import { createRequestHandler, createCookieSessionStorage } from "@remix-run/cloudflare"
import * as build from "@remix-run/dev/server-build"

const handleRequest = createRequestHandler(build, process.env.NODE_ENV)

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext) {
    return handleRequest(request, {
      env,
      waitUntil: ctx.waitUntil.bind(ctx),
    })
  },
}

Cloudflare Workers run your Remix app on the edge, as close to users as possible. This means extremely low latency for the initial response. The trade-off is that you are working within the Cloudflare Workers runtime rather than Node.js, so some Node.js APIs may not be available. However, Remix's web-standards-first approach means most of your code works seamlessly on the edge.

Testing

Unit tests with Vitest

TSapp/utils/validators.test.ts
TypeScript
// app/utils/validators.test.ts
import { describe, it, expect } from 'vitest'
import { validateEmail, validatePassword } from './validators'

describe('validateEmail', () => {
  it('returns error for invalid email', () => {
    expect(validateEmail('invalid')).toBe('Invalid email address')
  })

  it('returns undefined for valid email', () => {
    expect(validateEmail('test@example.com')).toBeUndefined()
  })
})

Vitest is the recommended test runner for Remix projects. It is fast, has native TypeScript support, and is compatible with the Jest API, so migrating from Jest is painless. Use it for testing utility functions, validators, and business logic that does not depend on the Remix runtime.

Integration tests

TSapp/routes/posts.test.ts
TypeScript
// app/routes/posts.test.ts
import { createRemixStub } from '@remix-run/testing'
import { render, screen } from '@testing-library/react'
import PostsRoute, { loader } from './posts._index'

describe('Posts route', () => {
  it('renders posts list', async () => {
    const RemixStub = createRemixStub([
      {
        path: '/posts',
        Component: PostsRoute,
        loader: () => ({
          posts: [
            { id: '1', title: 'First Post' },
            { id: '2', title: 'Second Post' },
          ]
        }),
      },
    ])

    render(<RemixStub initialEntries={['/posts']} />)

    expect(await screen.findByText('First Post')).toBeInTheDocument()
    expect(await screen.findByText('Second Post')).toBeInTheDocument()
  })
})

The createRemixStub utility from @remix-run/testing lets you test route components in isolation. You can provide mock loaders and actions, simulate navigation, and verify that your components render correctly based on the data they receive. This is particularly useful for testing complex route interactions without spinning up a real server.

E2E tests with Playwright

TSe2e/posts.spec.ts
TypeScript
// e2e/posts.spec.ts
import { test, expect } from '@playwright/test'

test.describe('Blog posts', () => {
  test('can create a new post', async ({ page }) => {
    await page.goto('/login')
    await page.fill('[name="email"]', 'test@example.com')
    await page.fill('[name="password"]', 'password123')
    await page.click('button[type="submit"]')

    await page.goto('/posts/new')
    await page.fill('[name="title"]', 'My Test Post')
    await page.fill('[name="content"]', 'This is the content of my test post.')
    await page.click('button[type="submit"]')

    await expect(page).toHaveURL(/\/posts\/[a-z0-9]+/)
    await expect(page.locator('h1')).toHaveText('My Test Post')
  })
})

Playwright provides end-to-end testing that exercises the full application stack - the browser, the Remix server, and the database. This is the most reliable way to verify that forms, navigation, authentication, and data flows work correctly from the user's perspective.

Best practices

Code organization

Code
TEXT
app/
├── components/
│   ├── ui/              # Generic UI components
│   │   ├── Button.tsx
│   │   ├── Input.tsx
│   │   └── Modal.tsx
│   └── features/        # Feature-specific components
│       ├── posts/
│       │   ├── PostCard.tsx
│       │   └── PostList.tsx
│       └── comments/
│           └── CommentForm.tsx
├── routes/
├── utils/
│   ├── db.server.ts     # Database client
│   ├── session.server.ts # Session management
│   └── validators.ts    # Form validation
├── models/              # Business logic
│   ├── post.server.ts
│   └── user.server.ts
└── styles/

Keep your route files thin. Routes should primarily handle data loading (loaders), mutations (actions), and composing components. Business logic belongs in the models/ directory, UI components in components/, and shared utilities in utils/. Files ending in .server.ts are guaranteed to never be included in the client bundle - Remix strips them during the build process.

Separation of concerns

TSapp/models/post.server.ts
TypeScript
// app/models/post.server.ts - Business logic
import { db } from '~/utils/db.server'

export async function getPosts(page: number, limit: number = 10) {
  return db.post.findMany({
    where: { published: true },
    orderBy: { createdAt: 'desc' },
    skip: (page - 1) * limit,
    take: limit,
    include: { author: true }
  })
}

export async function createPost(data: CreatePostInput, authorId: string) {
  return db.post.create({
    data: {
      ...data,
      authorId,
    }
  })
}

// app/routes/posts._index.tsx - Route uses the model
import { getPosts } from '~/models/post.server'

export async function loader({ request }: LoaderFunctionArgs) {
  const url = new URL(request.url)
  const page = parseInt(url.searchParams.get('page') || '1')

  const posts = await getPosts(page)

  return json({ posts })
}

By extracting business logic into model files, your route files stay focused on HTTP concerns (parsing request parameters, returning responses), and your business logic becomes reusable and easier to test. The model functions can be called from multiple routes, tested independently, and refactored without touching the route definitions.

FAQ - frequently asked questions

How does Remix differ from Next.js?

Remix focuses on web standards and progressive enhancement. Forms work without JS, data is loaded in parallel for nested routes, and mutations use native form actions. Next.js offers more options (SSG, ISR, React Server Components) but requires more JavaScript on the client side.

Is Remix production-ready?

Yes. Remix is used by large companies like Shopify, NASA, and Netflix. After the acquisition by Shopify it has solid funding and active development.

Can I use React Server Components in Remix?

Not currently. Remix has its own approach to server-side data loading through loaders. The Remix team monitors RSC but the priority is progressive enhancement.

How do I implement infinite scroll in Remix?

Use useFetcher to load subsequent pages without a full navigation:

Code
TypeScript
function InfiniteList() {
  const { posts } = useLoaderData<typeof loader>()
  const fetcher = useFetcher()
  const [allPosts, setAllPosts] = useState(posts)

  useEffect(() => {
    if (fetcher.data?.posts) {
      setAllPosts(prev => [...prev, ...fetcher.data.posts])
    }
  }, [fetcher.data])

  const loadMore = () => {
    const page = Math.ceil(allPosts.length / 10) + 1
    fetcher.load(`/posts?page=${page}`)
  }

  return (
    <div>
      {allPosts.map(post => <PostCard key={post.id} {...post} />)}
      <button onClick={loadMore} disabled={fetcher.state === 'loading'}>
        Load More
      </button>
    </div>
  )
}

The useFetcher hook lets you make requests to any route's loader without triggering a navigation. This means the URL stays the same and the existing page content remains intact while new data loads in the background. Combined with local state, you can accumulate results from multiple pages to build an infinite scroll experience.

How do I handle file uploads?

Code
TypeScript
export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData()
  const file = formData.get('file') as File

  const buffer = Buffer.from(await file.arrayBuffer())
  const filename = `${Date.now()}-${file.name}`

  await uploadToStorage(buffer, filename)

  return json({ success: true, filename })
}

File uploads in Remix work the same way they do in any standard web application - through multipart form data. The browser sends the file as part of the form submission, and the action receives it via request.formData(). From there you can process the file buffer and upload it to any storage service like S3, Cloudinary, or your local filesystem.

Summary

Remix is a framework for developers who want to harness the potential of the web platform instead of fighting against its limitations. Key features:

  • Progressive Enhancement - Applications work without JavaScript and improve when JS is available
  • Nested Routes - Hierarchical structure with parallel data loading
  • Web Standards - HTTP, HTML forms, and cookies work natively
  • Full Type Safety - Complete TypeScript integration
  • Error Boundaries - Granular error handling at the level of each route

Remix is ideal for building applications where accessibility, SEO, and performance are priorities. If you value clean architecture, minimal client-side JavaScript, and a framework that embraces the web rather than abstracting it away, Remix is an excellent choice for your next project.