Skip to content
Learni
View all tutorials
Frameworks Web

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

Lire en français

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

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

This 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

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

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

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

app/sessions.ts
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

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 { 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

package.json (scripts) + app/root.tsx
{
  "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 LoaderFunctionArgs everywhere for autocompletion.
  • Secure sessions: Always httpOnly, secure in prod, rotate secrets.
  • Fine-grained revalidation: revalidatePath or useRevalidator for partial updates.
  • DB perf: Explicit Prisma selects (select: { id: true, title: true }), raw queries for complexity.
  • Testing: @remix-run/testing for isolated loaders/actions.

Common Errors to Avoid

  • Forgetting await in 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.