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
- Web Standards First - HTTP, formularze HTML, cookies działają tak jak powinny
- Progressive Enhancement - Aplikacja działa bez JavaScript i ulepsza się gdy JS jest dostępny
- Nested Routes - Hierarchiczna struktura route'ów z automatycznym dziedziczeniem layoutów
- Data Loading - Loaders ładują dane równolegle dla wszystkich nested routes
- Mutations - Actions obsługują formularze bez client-side state management
- Error Boundaries - Granularne error handling na poziomie każdego route'a
- SEO Friendly - Pełne SSR z kontrolą nad meta tagami i headers
- Type Safety - Pełna integracja z TypeScript
Remix vs Next.js vs Astro
| Cecha | Remix | Next.js | Astro |
|---|---|---|---|
| Routing | Nested, file-based | File-based, App Router | File-based |
| Data Loading | Loaders (server) | Server Components, fetch | Astro.props |
| Mutations | Actions + Forms | Server Actions | API endpoints |
| Progressive Enhancement | ✅ Natywne | ❌ Wymaga JS | Częściowe |
| Streaming | ✅ | ✅ | ✅ |
| Edge Runtime | ✅ | ✅ | ✅ |
| Static Export | Częściowe | ✅ | ✅ |
| React Server Components | ❌ | ✅ | ❌ |
| Learning Curve | Średnia | Wysoka | Niska |
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
# 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/remixDostępne oficjalne szablony (Stacks)
# 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-stackStruktura projektu
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.jsonKonfiguracja Remix
// 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:
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 /healthcheckKonwencje nazewnictwa
| Pattern | Przykład pliku | URL | Opis |
|---|---|---|---|
_index | _index.tsx | / | Index route |
nazwa | about.tsx | /about | Static route |
$param | posts.$id.tsx | /posts/123 | Dynamic segment |
. (dot) | posts.new.tsx | /posts/new | Nested route |
_ (prefix) | _auth.login.tsx | /login | Pathless layout |
_ (suffix) | blog_.tsx | - | Layout escape |
($param) | ($lang).about.tsx | /about lub /pl/about | Optional segment |
$ | files.$.tsx | /files/* | Splat route |
Nested Routes i Layouts
// 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>
)
}// 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)
// 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-passwordLoaders - Server-side Data Loading
Podstawowy loader
// 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
// 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:
// 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 analyticsLoader z autentykacją
// 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
// 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
// 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
// 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
// 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
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
// 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>
)
}// 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
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
// 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
// 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
// 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
// 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
// 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
// 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 }// 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
// 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
// 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
}// 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)
// 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
// remix.config.js
module.exports = {
serverBuildTarget: "vercel",
server: process.env.NODE_ENV === "development" ? undefined : "./server.js",
}// vercel.json
{
"buildCommand": "remix build",
"devCommand": "remix dev",
"installCommand": "npm install",
"framework": "remix"
}Fly.io
# 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 /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM base AS production
WORKDIR /app
ENV NODE_ENV=production
COPY /app/node_modules ./node_modules
COPY /app/build ./build
COPY /app/public ./public
COPY /app/package.json ./
EXPOSE 3000
CMD ["npm", "start"]# 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
// 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
// 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
// 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
// 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
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
// 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:
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?
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
- Web Standards First - HTTP, HTML forms, and cookies work the way they should
- Progressive Enhancement - The application works without JavaScript and improves when JS is available
- Nested Routes - Hierarchical route structure with automatic layout inheritance
- Data Loading - Loaders fetch data in parallel for all nested routes
- Mutations - Actions handle forms without client-side state management
- Error Boundaries - Granular error handling at the level of each route
- SEO Friendly - Full SSR with control over meta tags and headers
- Type Safety - Full TypeScript integration
Remix vs Next.js vs Astro
| Feature | Remix | Next.js | Astro |
|---|---|---|---|
| Routing | Nested, file-based | File-based, App Router | File-based |
| Data Loading | Loaders (server) | Server Components, fetch | Astro.props |
| Mutations | Actions + Forms | Server Actions | API endpoints |
| Progressive Enhancement | Native | Requires JS | Partial |
| Streaming | Yes | Yes | Yes |
| Edge Runtime | Yes | Yes | Yes |
| Static Export | Partial | Yes | Yes |
| React Server Components | No | Yes | No |
| Learning Curve | Medium | High | Low |
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
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/remixAvailable official templates (Stacks)
# 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-stackProject structure
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.jsonRemix configuration
// 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:
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 /healthcheckNaming conventions
| Pattern | Example file | URL | Description |
|---|---|---|---|
_index | _index.tsx | / | Index route |
name | about.tsx | /about | Static route |
$param | posts.$id.tsx | /posts/123 | Dynamic segment |
. (dot) | posts.new.tsx | /posts/new | Nested route |
_ (prefix) | _auth.login.tsx | /login | Pathless layout |
_ (suffix) | blog_.tsx | - | Layout escape |
($param) | ($lang).about.tsx | /about or /pl/about | Optional segment |
$ | files.$.tsx | /files/* | Splat route |
Nested routes and layouts
// 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>
)
}// 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)
// 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-passwordLoaders - server-side data loading
Basic loader
// 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
// 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:
// 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
// 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
// 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
// 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
// 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
// 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
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
// 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>
)
}// 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
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
// 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
// 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
// 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
// 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
// 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
// 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.
// 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
// 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
// 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.
// 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)
// 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
// remix.config.js
module.exports = {
serverBuildTarget: "vercel",
server: process.env.NODE_ENV === "development" ? undefined : "./server.js",
}// 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
# 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 /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM base AS production
WORKDIR /app
ENV NODE_ENV=production
COPY /app/node_modules ./node_modules
COPY /app/build ./build
COPY /app/public ./public
COPY /app/package.json ./
EXPOSE 3000
CMD ["npm", "start"]# 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
// 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
// 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
// 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
// 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
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
// 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:
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?
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.