Introduction
In 2026, Remix leads React full-stack frameworks by embracing web standards: no unnecessary client-side JavaScript, native forms supercharged by actions and loaders for fresh data on every navigation. Unlike Next.js App Router, Remix shines with smooth transitions and optimistic UI without bloated global state.
This advanced tutorial guides you through building a secure todo-list app: authentication, CRUD with Prisma (PostgreSQL), nested routes, error handling, and Vercel deployment. You'll master deferred for streaming, native error boundaries, and Lucia auth. By the end, you'll have a production-ready, scalable, SEO-friendly project. Perfect for seniors optimizing web vitals. (128 words)
Prerequisites
- Node.js 20+ and PNPM 9+
- Advanced knowledge of React, TypeScript, and SQL
- PostgreSQL account (e.g., Neon or Supabase)
- Vercel account for deployment
- Git installed
Setting Up the Remix Project
pnpm create remix@latest mon-app-remix --typescript --deployment vercel
cd mon-app-remix
pnpm install @prisma/client prisma lucia-auth @lucia-auth/adapter-prisma bcryptjs @remix-run/node
pnpm prisma init --datasource-provider postgresql
del .env.example
pnpm devThis command creates a new TypeScript Remix project optimized for Vercel, installs Prisma for the ORM, Lucia for session-based auth, and bcryptjs for hashing. prisma init sets up the DB schema with PostgreSQL. Run pnpm dev for hot-reload on localhost:3000. Pitfall: Check your DATABASE_URL in .env.
Database Configuration
Remix excels with server-first data handling. We use Prisma for its infallible TypeScript type generation and zero-downtime migrations. Add your DATABASE_URL to .env, then define the schema for users and todos.
Complete Prisma Schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
password String
name String?
todos Todo[]
sessions Session[]
accounts Account[]
@@map("users")
}
model Todo {
id String @id @default(cuid())
title String
completed Boolean @default(false)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@map("todos")
}
model Account {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
email String
password String?
@@unique([email])
@@map("accounts")
}
model Session {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
expiresAt DateTime
idleExpiresAt DateTime?
@@unique([id])
@@map("sessions")
}This schema defines User for auth, Todo linked by foreign key, and Session for Lucia. Use CUID for short/distributed IDs. @@map lowercases Postgres tables. Run pnpm prisma db push then pnpm prisma generate for TS types. Pitfall: Always use onDelete: Cascade for cleanup.
Lucia Authentication Middleware
import { Auth } from "lucia-auth";
import { adapter } from "./prisma-auth";
import { prisma } from "./db.server";
import { cookies } from "@remix-run/node";
const auth = new Auth(
adapter(prisma),
{
sessionCookie: {
attributes: {
secure: process.env.NODE_ENV === "production",
httpOnly: true,
},
},
}
);
export type User = typeof auth.user;
export const getUser = async (cookiesHeader: string) => {
const sessionId = auth.parseCookie(cookiesHeader);
if (!sessionId) return null;
const session = await auth.getSession(sessionId);
return session.user;
};
export const logout = async (request: Request) => {
const sessionCookie = cookies.headers(request);
const sessionId = auth.parseCookie(sessionCookie);
if (!sessionId) return;
auth.invalidateSession(sessionId);
};
export default auth;This module centralizes Lucia with a Prisma adapter for DB sessions. getUser extracts the user via Remix cookie. Secured with HttpOnly/secure in production. Pitfall: Never store sessions in memory; use DB for persistence.
Basic Routes with Loaders and Actions
Loaders fetch server-side data on every navigation (no forced client cache). Actions handle POST/PUT/DELETE mutations with automatic revalidation. Analogy: Loader = server-side useEffect, Action = boosted onSubmit.
Protected Index Route with Loader
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Link, Outlet, useLoaderData } from "@remix-run/react";
import { getUser } from "~/lib/auth.server";
import { prisma } from "~/lib/db.server";
export async function loader({ request }: LoaderFunctionArgs) {
const user = await getUser(request.headers.get("Cookie") || "");
if (!user) throw redirect("/login");
const todos = await prisma.todo.findMany({
where: { userId: user.id },
orderBy: { createdAt: "desc" },
});
return json({ todos, user });
}
export default function Index() {
const { todos, user } = useLoaderData<typeof loader>();
return (
<div>
<h1>Bonjours, {user.name || user.email}!</h1>
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.title} - {todo.completed ? "✓" : "○"}</li>
))}
</ul>
<Link to="new">Nouveau Todo</Link>
<Outlet />
</div>
);
}Loader protects the route (redirects if unauthenticated), fetches user-specific todos. Types inferred via typeof loader. Nested Outlet for child routes. Pitfall: Always type LoaderFunctionArgs for headers/params.
New Todo Route with Action and Optimistic UI
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { prisma } from "~/lib/db.server";
import { getUser } from "~/lib/auth.server";
export async function action({ request }: ActionFunctionArgs) {
const user = await getUser(request.headers.get("Cookie") || "");
if (!user) throw redirect("/login");
const formData = await request.formData();
const title = formData.get("title") as string;
if (!title || title.length < 3) {
return json({ error: "Titre trop court" }, { status: 400 });
}
await prisma.todo.create({
data: { title, userId: user.id },
});
return redirect("/");
}
export default function NewTodo() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
return (
<Form method="post">
<input
name="title"
placeholder="Nouveau todo"
required
className={navigation.state === "submitting" ? "submitting" : ""}
/>
{actionData?.error && <p>{actionData.error}</p>}
<button type="submit" disabled={navigation.state === "submitting"}>
Créer
</button>
</Form>
);
}Action validates and creates the todo, then redirects for revalidation. useActionData handles errors, useNavigation manages loading states (optimistic). Native POST form. Pitfall: Always validate on the server; client-side is malleable.
Error Handling and Deferred
Error boundaries catch throws from loaders/actions. deferred streams heavy data without blocking initial HTML, perfect for Core Web Vitals.
Root Layout with Error Boundary
import type { LinksFunction } from "@remix-run/node";
import {
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
isRouteErrorResponse,
} from "@remix-run/react";
import { useRouteError } from "@remix-run/react";
import { Link } from "@remix-run/react";
import { json } from "@remix-run/node";
import { getUser } from "~/lib/auth.server";
import type { LoaderFunctionArgs } from "@remix-run/node";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await getUser(request.headers.get("Cookie") || "");
return json({ user });
};
export function ErrorBoundary() {
const error = useRouteError();
console.error(error);
return (
<html>
<head>
<title>Erreur!</title>
<Meta />
<Links />
</head>
<body>
<h1>Erreur 500</h1>
<p>Quelque chose a mal tourné.</p>
<Link to="/">Accueil</Link>
<Scripts />
</body>
</html>
);
}
export default function App() {
return (
<html lang="fr">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}ErrorBoundary wraps Outlet to catch route errors. Root loader fetches global user. Scripts/LiveReload enable HMR. Pitfall: Always log errors in production with Sentry.
Login Route with Auth Action
import type { ActionFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";
import { prisma } from "~/lib/db.server";
import auth from "~/lib/auth.server";
import bcrypt from "bcryptjs";
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 prisma.user.findUnique({ where: { email } });
if (!user || !await bcrypt.compare(password, user.password)) {
return json({ error: "Invalid credentials" }, { status: 400 });
}
const session = await auth.createSession(user.id);
const cookie = await auth.createSessionCookie(session.id);
return redirect("/", {
headers: { "Set-Cookie": cookie },
});
}
export default function Login() {
const actionData = useActionData<typeof action>();
return (
<Form method="post">
<input name="email" type="email" required />
<input name="password" type="password" required />
{actionData?.error && <p>{actionData.error}</p>}
<button type="submit">Login</button>
</Form>
);
}Action checks hashed password with bcrypt, creates Lucia session, sets Remix cookie. Secure redirect. Add a similar register route. Pitfall: Always hash passwords; never store plain-text in DB.
Vercel Deployment
Push to Git, connect Vercel: it auto-detects Remix, handles Prisma preview DBs. Add pnpm prisma generate and prisma db push to build hooks.
Build Script and vercel.json
{
"build": {
"command": "pnpm build",
"env": {
"PRISMA_GENERATE_SKIP_AUTOINSTALL": "true"
}
},
"installCommand": "pnpm install",
"functions": {
"app/api/**/*": {
"maxDuration": 30
}
}
}Vercel config for PNPM, skips Prisma auto-install (vuln), sets 30s API timeout. Build auto-generates Prisma client. Pitfall: Separate DATABASE_URL for preview/prod.
Best Practices
- Always server-first: Loaders for data fetching, zero client useEffect.
- Use
shouldRevalidatefor fine-grained caching (e.g., skip on actions). - Integrate
deferredfor analytics/lazy data:return deferred({ data: slowQuery() }). - Global auth middleware via root loader.
- E2E tests with Playwright on actions/loaders.
Common Errors to Avoid
- Forgetting
headers.get('Cookie')in loaders: auth lost. - Mutations without
redirect: stale client data. - Ignoring
useFetcherfor side-effects (e.g., likes): no revalidation. - Unindexed DB queries: fails to scale in production.
Next Steps
Dive into Remix docs, Lucia Auth, and Prisma Accelerate for edge caching. Check our Learni full-stack trainings to master Remix in teams. Contribute on GitHub for advanced nested errors.