Skip to content
Learni
View all tutorials
Remix

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

Lire en français

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

terminal
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 dev

This 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

prisma/schema.prisma
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

app/lib/auth.server.ts
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

app/routes/_index.tsx
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

app/routes/new.tsx
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

app/root.tsx
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

app/routes/login.tsx
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

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 shouldRevalidate for fine-grained caching (e.g., skip on actions).
  • Integrate deferred for 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 useFetcher for 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.