Skip to content
Learni
View all tutorials
Développement Web

How to Build an Expert Full-Stack App with Remix in 2026

Lire en français

Introduction

In 2026, Remix stands out as the premier full-stack React framework for performant, scalable web apps. Unlike Next.js, which separates API and frontend, Remix unifies everything with a data-first paradigm: loaders fetch data on the server before rendering, actions handle mutations optimistically, and native forms skip unnecessary abstractions.

This expert tutorial guides you through building a complete Todo app: CRUD with Prisma (SQLite), session-based authentication, nested routes, optimistic UI via useFetcher, and advanced error handling. Ideal for seniors mastering Remix v3+'s React Server Components-inspired features.

Why Remix? Native SSR performance, nested routes for modular architecture, and zero-config deployment on Vercel/Netlify. By the end, you'll have a production-ready project to bookmark as a reference. (148 words)

Prerequisites

  • Node.js 20+ and pnpm 9+
  • Proficiency in React, TypeScript, and advanced hooks
  • Knowledge of relational databases (SQL)
  • Tools: VS Code with Remix/Prisma extensions
  • Estimated time: 45 min to implement

Initialize the Remix Project

terminal
pnpm create remix@latest mon-app-remix --typescript
cd mon-app-remix
pnpm install @prisma/client prisma @remix-run/node
pnpm prisma init --datasource-provider sqlite
del remix.config.js
pnpm run dev

This command creates a Remix v3+ project with TypeScript, installs Prisma for SQLite (perfect for dev/prototypes), and removes the outdated JS config. Run pnpm run dev to verify at http://localhost:3000. Pitfall: Don't forget prisma init to generate schema.prisma.

Project Structure and Root Layout

Remix organizes code with file-based routing: app/routes/ maps directly to URLs. The app/root.tsx defines the global layout, including , , , and .

For experts, enable headers for CSP/security and Live Reload. We're setting up a root with Outlet for nested routes and an optional global loader. Analogy: like a tree, root is the trunk, routes are the branches.

Set Up app/root.tsx

app/root.tsx
import type { LinksFunction } from '@remix-run/node';
import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration } from '@remix-run/react';

export const links: LinksFunction = () => [
  { rel: 'preconnect', href: 'https://fonts.googleapis.com' },
  { rel: 'preconnect', href: 'https://fonts.gstatic.com', crossOrigin: 'anonymous' },
];

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

This minimal yet pro root.tsx includes preconnects for fonts (perf boost), Outlet for nested routes, and all Remix primitives for SSR hydration. Adds LiveReload for HMR. Pitfall: Without , styles won't load; test with pnpm run dev.

Update entry.server.tsx for Sessions

app/entry.server.tsx
import type { EntryContext } from '@remix-run/node';
import { RemixServer } from '@remix-run/react';
import { renderToString } from 'react-dom/server';
import { createCookieFactory, createSessionStorageFactory } from '@remix-run/node';

const sessionSecret = process.env.SESSION_SECRET || 'dev-secret';
const cookie = createCookieFactory('remix-todo-session', {
  secrets: [sessionSecret],
  sameSite: 'lax',
  httpOnly: true,
  maxAge: 60 * 60 * 24 * 7,
});

export const sessionStorage = createSessionStorageFactory({
  cookie,
});

export default async function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  routerContext: EntryContext
) {
  const markup = await renderToString(
    <RemixServer context={routerContext} url={request.url} />
  );
  responseHeaders.set('Content-Type', 'text/html');
  return new Response('<!DOCTYPE html>' + markup, {
    status: responseStatusCode,
    headers: responseHeaders,
  });
}

Prepares sessions for expert auth with secure cookies (httpOnly, sameSite). Uses Remix v3+ factories. Pitfall: Set SESSION_SECRET in .env for prod; without it, sessions fail over HTTPS.

Implement the Index Route with Loader and Action

Loaders fetch data server-side before rendering (no waterfalls). Actions handle POST/PUT/DELETE, revalidating via revalidatePath. Here, basic CRUD for Todos without a DB yet (in-memory for demo).

For experts, use useLoaderData and native Form. Analogy: loader = prefetch, action = optimistic update.

Create app/routes/index.tsx

app/routes/index.tsx
import type { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node';
import type { ShouldRevalidateFunction } from '@remix-run/react';
import { Form, useActionData, useLoaderData, useRevalidator } from '@remix-run/react';
import { json } from '@remix-run/node';

type Todo = { id: string; text: string; done: boolean };
let todos: Todo[] = [];

export const loader = async ({ request }: LoaderFunctionArgs) => {
  return json({ todos });
};

export const action = async ({ request }: ActionFunctionArgs) => {
  const formData = await request.formData();
  const action = formData.get('action') as string;
  const text = formData.get('text') as string;
  if (action === 'create' && text) {
    todos.push({ id: crypto.randomUUID(), text, done: false });
  } else if (action === 'toggle' && formData.has('id')) {
    const id = formData.get('id') as string;
    const todo = todos.find(t => t.id === id);
    if (todo) todo.done = !todo.done;
  } else if (action === 'delete' && formData.has('id')) {
    const id = formData.get('id') as string;
    todos = todos.filter(t => t.id !== id);
  }
  return json({ todos }, { status: 201 });
};

export const shouldRevalidate: ShouldRevalidateFunction = ({ actionResult }) => {
  return !actionResult;
};

export default function Index() {
  const { todos } = useLoaderData<typeof loader>();
  const actionData = useActionData<typeof action>();
  const revalidator = useRevalidator();
  return (
    <div style={{ padding: '2rem', fontFamily: 'system-ui' }}>
      <h1>Mes Todos (Remix Expert)</h1>
      <Form method="post">
        <input name="text" placeholder="Nouveau todo" />
        <button name="action" value="create">Ajouter</button>
      </Form>
      <ul>
        {todos.map(todo => (
          <li key={todo.id} style={{ display: 'flex', gap: '1rem' }}>
            <span style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>{todo.text}</span>
            <Form method="post">
              <input type="hidden" name="id" value={todo.id} />
              <button name="action" value="toggle">Toggle</button>
              <button name="action" value="delete" style={{ color: 'red' }}>Suppr</button>
            </Form>
          </li>
        ))}
      </ul>
    </div>
  );
}

Complete route: loader returns todos, action handles CRUD (create/toggle/delete). shouldRevalidate optimizes (no unnecessary re-fetch). UI with native Form for progressive enhancement. Pitfall: Use crypto.randomUUID() (Node 20+), or add a polyfill fallback.

Integrate Prisma for Persistence

Upgrade to a real DB: Prisma generates type-safe TS client. Simple schema for Todo + User. Auto-migrations.

Expert tip: Use Promise.all in loaders for parallel fetches, defer for streaming (non-blocking UI).

Prisma Schema and Migration

prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = "file:./dev.db"
}

model User {
  id    String @id @default(uuid())
  email String @unique
  todos Todo[]
}

model Todo {
  id          String @id @default(uuid())
  text        String
  done        Boolean @default(false)
  userId      String
  user        User   @relation(fields: [userId], references: [id])
  createdAt   DateTime @default(now())
}

Relational User-Todo schema (1:N). UUIDs for scalable IDs. Run pnpm prisma db push then pnpm prisma generate. Pitfall: SQLite for dev; migrate to PostgreSQL in prod via url = env(DATABASE_URL).

Update Loader/Action with Prisma

app/routes/index.tsx
import { PrismaClient } from '@prisma/client';
import type { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node';
import type { ShouldRevalidateFunction } from '@remix-run/react';
import { Form, useActionData, useLoaderData, useRevalidator } from '@remix-run/react';
import { json, redirect } from '@remix-run/node';

const prisma = new PrismaClient();

export const loader = async ({ request }: LoaderFunctionArgs) => {
  const todos = await prisma.todo.findMany({ include: { user: true } });
  return json({ todos });
};

export const action = async ({ request }: ActionFunctionArgs) => {
  const formData = await request.formData();
  const actionType = formData.get('action') as string;
  const userId = 'demo-user-id'; // À remplacer par session.userId
  if (actionType === 'create') {
    const text = formData.get('text') as string;
    await prisma.todo.create({ data: { text, userId } });
  } else if (actionType === 'toggle') {
    const id = formData.get('id') as string;
    const todo = await prisma.todo.findUnique({ where: { id } });
    if (todo) await prisma.todo.update({ where: { id }, data: { done: !todo.done } });
  } else if (actionType === 'delete') {
    const id = formData.get('id') as string;
    await prisma.todo.delete({ where: { id } });
  }
  return json({ success: true });
};

export const shouldRevalidate: ShouldRevalidateFunction = ({ actionResult }) => {
  return !(actionResult as any)?.success;
};

export default function Index() {
  const { todos } = useLoaderData<typeof loader>();
  const actionData = useActionData<typeof action>();
  return (
    <div style={{ padding: '2rem', fontFamily: 'system-ui' }}>
      <h1>Todos Prisma</h1>
      <Form method="post">
        <input name="text" placeholder="Nouveau todo" />
        <button name="action" value="create">Ajouter</button>
      </Form>
      <ul>
        {todos.map((todo: any) => (
          <li key={todo.id} style={{ display: 'flex', gap: '1rem' }}>
            <span style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>{todo.text} ({todo.user.email})</span>
            <Form method="post">
              <input type="hidden" name="id" value={todo.id} />
              <button name="action" value="toggle">Toggle</button>
              <button name="action" value="delete">Suppr</button>
            </Form>
          </li>
        ))}
      </ul>
    </div>
  );
}

Integrates Prisma: loader fetches with include (relations), action performs type-safe mutations. userId hardcoded (link to auth later). Optimized with shouldRevalidate. Pitfall: No need for prisma.$disconnect() in dev, but wrap in prod to avoid leaks.

Authentication with Sessions and useFetcher

Remix sessions: Store userId in signed cookies. Protectors: Check session in loader.

Expert: useFetcher for optimistic mutations (pending states, no navigation).

Auth Middleware and lib/auth.ts

app/lib/auth.ts
import { createCookieSessionStorage } from '@remix-run/node';
import type { Session } from '@remix-run/node';

const sessionSecret = process.env.SESSION_SECRET || 's3cret';
const storage = 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 storage.getSession(cookie);
}

export async function requireUserId(
  request: Request,
  redirectTo: string = new URL(request.url).pathname
) {
  const session = await getSession(request);
  const userId = session.get('userId');
  if (!userId || typeof userId !== 'string') {
    const searchParams = new URLSearchParams([['redirectTo', redirectTo]]);
    throw redirect(`/login?${searchParams.toString()}`);
  }
  return userId;
}

export function commitSession(session: Session) {
  return storage.commitSession(session);
}

Utility lib: getSession, requireUserId (protects loader/action), commitSession. Production-ready secure. Pitfall: Set secure: true in prod for HTTPS-only.

Login Route and Optimistic useFetcher

app/routes/login.tsx
import type { ActionFunctionArgs } from '@remix-run/node';
import { Form, useFetcher } from '@remix-run/react';
import { json, redirect } from '@remix-run/node';
import { getSession } from '~/lib/auth';

export const action = async ({ request }: ActionFunctionArgs) => {
  const formData = await request.formData();
  const email = formData.get('email') as string;
  const session = await getSession(request);
  session.set('userId', 'user-' + email.split('@')[0]);
  return redirect('/', {
    headers: {
      'Set-Cookie': await commitSession(session),
    },
  });
};

export default function Login() {
  const fetcher = useFetcher();
  return (
    <div style={{ padding: '2rem' }}>
      <fetcher.Form method="post">
        <input name="email" type="email" placeholder="email@example.com" />
        <button type="submit" disabled={fetcher.state !== 'idle'}>Login</button>
      </fetcher.Form>
    </div>
  );
}

Simple demo login route, sets session.userId. useFetcher for submit without full reload, pending state. Redirect with Set-Cookie. Pitfall: Import commitSession from lib.

Nested Routes and Error Boundaries

Nested routes: routes/resources._index.tsx for child indexes. ErrorBoundary: Catches granular errors.

Add app/routes/resources._index.tsx for filtered todos.

Nested Route and Error Boundary

app/routes/resources._index.tsx
import type { LoaderFunctionArgs } from '@remix-run/node';
import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

export const loader = async ({ params }: LoaderFunctionArgs) => {
  if (!params['*']) throw new Error('Route invalide');
  const todos = await prisma.todo.findMany({ where: { done: false } });
  return json({ todos });
};

export function ErrorBoundary({ error }: { error: Error }) {
  return (
    <html>
      <head>
        <title>Erreur!</title>
      </head>
      <body>
        <h1>Erreur: {error.message}</h1>
        <p>Route nested échouée.</p>
      </body>
    </html>
  );
}

export default function ResourcesIndex() {
  const { todos } = useLoaderData<typeof loader>();
  return (
    <div>
      <h2>Todos non faits</h2>
      <ul>{todos.map((todo: any) => <li key={todo.id}>{todo.text}</li>)}</ul>
    </div>
  );
}

Catch-all nested route (resources._index), filters DB. Local ErrorBoundary (granular). Pitfall: Use params['*'] for splat routes; test at /resources.

Best Practices

  • Strict data flow: Always use loader/action, avoid useEffect fetches (Remix anti-pattern).
  • Optimistic UI: Combine useFetcher + useOptimistic for pending states.
  • Type safety: Generate Prisma types (prisma generate), use zod for formData validation.
  • Perf: defer({ slow: promise }) in loader for streaming HTML + data.
  • Security: Validate inputs with schemas, rate-limit actions, CSP headers in root.

Common Errors to Avoid

  • Forgetting revalidation: Without useRevalidator().revalidate() or shouldRevalidate, UI stays stale post-mutation.
  • useEffect for data: Breaks SSR, causes hydration mismatch; use loaders instead.
  • Uncommitted sessions: Missing Set-Cookie headers → immediate logout.
  • Prisma on client: Never! Keep DB queries server-only to avoid bundle leaks.

Next Steps