Introduction
Remix, the full-stack React framework, is revolutionizing web development in 2026 by prioritizing data and native forms. Unlike Next.js with its focus on static SSR, Remix shines in interactive apps through loaders (for server-side data loading) and actions (for mutating data via standard HTML forms).
This advanced tutorial walks you through creating a task management app: authentication, CRUD with Prisma (SQLite for simplicity), error handling, optimistic UI, and Vercel deployment. Ideal for senior developers scaling performant apps without excessive boilerplate.
Why Remix? It eliminates data waterfalls, handles network reconnections automatically, and optimizes bundles. By the end, you'll have a production-ready project to bookmark as a reference. (128 words)
Prerequisites
- Node.js 20+ installed
- Advanced knowledge of React and TypeScript
- GitHub and Vercel accounts for deployment
- Tools: Prisma CLI, Remix CLI (
npm create remix@latest) - Editor: VS Code with Remix and Prisma extensions
Setting Up the Remix Project
npx create-remix@latest mon-app-remix --template remix-run/remix/templates/remix
cd mon-app-remix
npm install @prisma/client prisma
npm install @remix-run/auth lucide-react
npm install -D @types/node
npx prisma init --datasource-provider sqliteThis command creates a new Remix project with the official template, adds Prisma for SQLite DB, Remix Auth for authentication, and Lucide for icons. The --datasource-provider sqlite flag sets up Prisma for a local DB file, perfect for development without external setup. Avoid pure JS templates; TypeScript is essential for advanced use.
Project Structure
Remix uses the filesystem for routing: app/routes/ defines URLs. app/root.tsx is the entry point, with for nested routes. Loaders and actions live in route files. Prisma generates prisma/schema.prisma and prisma/migrations/.
Analogy: like a tree, routes/_index.tsx handles /, routes/taches.$id.tsx manages /taches/123. This makes code scalable without centralized config.
Prisma Configuration and Models
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
model User {
id String @id @default(cuid())
email String @unique
name String?
tasks Task[]
}
model Task {
id String @id @default(cuid())
title String
completed Boolean @default(false)
userId String
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
}
model Session {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
}Defines User, Task, and Session models for auth/CRUD. CUID for distributed IDs, relations for integrity. SQLite via file:./dev.db for portability. Run npx prisma generate && npx prisma db push afterward to generate the client and migrate.
Index Route with Authenticated Loader
import type { LoaderFunctionArgs } from '@remix-run/node';
import { json } from '@remix-run/node';
import { Link, Outlet, useLoaderData } from '@remix-run/react';
import { getSession } from '~/sessions';
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
const userId = session.get('userId');
if (!userId) throw new Response('Unauthorized', { status: 401 });
return json({ userId });
}
export default function Index() {
const { userId } = useLoaderData<typeof loader>();
return (
<div>
<h1>Bienvenue !</h1>
<p>ID User: {userId}</p>
<Link to="/taches">Mes tâches</Link>
<Outlet />
</div>
);
}Loader runs server-side to fetch data/auth. Typed useLoaderData enables auto-revalidation. Session check prevents CSRF. Pitfall: forgetting request in args causes runtime errors; always type LoaderFunctionArgs.
Loaders and Actions Mechanism
Loaders: Load data before rendering, like getServerSideProps but per-route with caching and parallelism. Actions: Handle mutations via , revalidating loaders post-submit.
Advantages: No useEffect for data fetching, resilient networking. Nested routes inherit from parents.
Tasks Route with CRUD and Prisma
import type { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node';
import { json, redirect } from '@remix-run/node';
import { Form, useActionData, useLoaderData, useNavigation } from '@remix-run/react';
import { PrismaClient } from '@prisma/client';
import { getSession } from '~/sessions';
const prisma = new PrismaClient();
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
const userId = session.get('userId');
const tasks = await prisma.task.findMany({ where: { userId } });
return json({ tasks });
}
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
const userId = session.get('userId');
const formData = await request.formData();
const intent = formData.get('intent');
if (intent === 'create') {
const title = formData.get('title') as string;
await prisma.task.create({ data: { title, userId } });
} else if (intent === 'toggle') {
const id = formData.get('id') as string;
const task = await prisma.task.findUnique({ where: { id } });
if (task?.userId === userId) {
await prisma.task.update({ where: { id }, data: { completed: !task.completed } });
}
}
return redirect('/taches');
}
export default function Taches() {
const { tasks } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const isSubmitting = navigation.state === 'submitting';
return (
<div>
<h2>Mes tâches</h2>
<Form method="post">
<input name="title" placeholder="Nouvelle tâche" />
<button name="intent" value="create" disabled={isSubmitting}>Ajouter</button>
</Form>
<ul>
{tasks.map((task: any) => (
<li key={task.id}>
<Form method="post">
<input type="hidden" name="id" value={task.id} />
<input type="hidden" name="intent" value="toggle" />
<label>
<input type="checkbox" checked={task.completed} readOnly />
{task.title}
</label>
<button type="submit" disabled={isSubmitting}>Toggle</button>
</Form>
</li>
))}
</ul>
</div>
);
}Loader queries DB by user, action handles create/toggle with session validation. useNavigation enables optimistic UI (disables buttons). PrismaClient singleton for performance. Pitfall: Skipping redirect after mutation prevents revalidation; always use formData.get('intent') for multi-actions.
Error Handling and Boundaries
Remix provides ErrorBoundary and CatchBoundary per route, with global ones in root.tsx. For advanced use: Leverage handleError in loaders/actions for custom logging.
Session Helpers and ErrorBoundary
import { createCookieSessionStorage } from '@remix-run/node';
const sessionSecret = process.env.SESSION_SECRET || 'dev-secret';
export const sessionStorage = createCookieSessionStorage({
cookie: {
name: '__session',
httpOnly: true,
path: '/',
sameSite: 'lax',
secrets: [sessionSecret],
secure: process.env.NODE_ENV === 'production',
},
});
export async function getSession(request: Request) {
const cookie = request.headers.get('Cookie');
return await sessionStorage.getSession(cookie);
}
export async function createUserSession({
request,
userId,
remember,
}: {
request: Request;
userId: string;
remember?: boolean;
}) {
const session = await getSession(request);
session.set('userId', userId);
return sessionStorage.commitSession(session, {
maxAge: remember ? 60 * 60 * 24 * 7 : undefined,
});
}Secure session storage with Remix. createUserSession for login. Env secrets for production. Pitfall: Without httpOnly and secure, vulnerable to XSS/CSRF. Use in login/register routes.
Login Route with Action
import type { ActionFunctionArgs } from '@remix-run/node';
import { json, redirect } from '@remix-run/node';
import { Form, useActionData } from '@remix-run/react';
import { createUserSession, getSession } from '~/sessions';
import { prisma } from '~/db';
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; // Hash en prod!
const user = await prisma.user.findUnique({ where: { email } });
if (!user || user.password !== password) { // Simplifié
return json({ error: 'Invalid credentials' }, { status: 400 });
}
return redirect('/', {
headers: {
'Set-Cookie': await createUserSession({ request, userId: user.id }),
},
});
}
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 validates login, sets cookie via headers. Errors returned to client. In production: Use bcrypt for hashing. Pitfall: Forgetting headers in redirect loses the session.
Deployment and Optimizations
remix.config.js for server builds. Deploy to Vercel with vercel --prod. Add prisma generate to build script.
package.json Scripts and root.tsx with Outlet
{
"scripts": {
"dev": "remix dev",
"build": "remix build && prisma generate",
"start": "remix-serve build/index.js",
"db:push": "prisma db push",
"db:seed": "prisma db seed"
}
}
// app/root.tsx
import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration, useRouteError } from '@remix-run/react';
import { getSession } from '~/sessions';
import type { LinksFunction } from '@remix-run/node';
export const links: LinksFunction = () => [
{ rel: 'stylesheet', href: 'https://fonts.googleapis.com/css?family=Roboto' },
];
export function ErrorBoundary() {
const error = useRouteError();
console.error(error);
return <h1>Erreur: {error instanceof Error ? error.message : 'Inconnu'}</h1>;
}
export default function App() {
return (
<html>
<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>
);
}Scripts for build/deploy with Prisma. root.tsx includes Outlet for nesting, global ErrorBoundary, and links for assets. Pitfall: Without , hydration fails.
Best Practices
- Strict typing: Use
LoaderFunctionArgseverywhere for autocompletion. - Secure sessions: Always
httpOnly,securein prod, rotate secrets. - Fine-grained revalidation:
revalidatePathoruseRevalidatorfor partial updates. - DB perf: Explicit Prisma selects (
select: { id: true, title: true }), raw queries for complexity. - Testing:
@remix-run/testingfor isolated loaders/actions.
Common Errors to Avoid
- Forgetting
awaitin actions: Async mutations without waiting cause race conditions. - Sessions without userId checks: Exposes cross-user data.
- No
useNavigation: Blocks UX without submit feedback. - DB on client: Prisma server-side only; mismanaged globals leak memory.
Next Steps
- Official docs: Remix.run
- Advanced: Streaming SSR, multi-env deployments.
- Learni Courses for expert Remix + React training.
- GitHub example: Fork this project, seed DB with
npx prisma db seed.