Remix 3.0: Full Stack Web Framework Guide
Remix 3.0 has reshaped the way developers think about full‑stack web applications, blending the best of server‑side rendering with the flexibility of modern client‑side interactivity. In this guide we’ll walk through the core concepts, set up a fresh project, explore data loading patterns, and even deploy a real‑world example. By the end you’ll be comfortable building production‑ready apps that feel snappy, SEO‑friendly, and easy to maintain.
Why Remix 3.0 Stands Out
Remix 3.0 builds on the solid foundation of its predecessors but introduces a declarative data fetching model that eliminates the “loading state” nightmare. Instead of sprinkling useEffect everywhere, you describe what data each route needs, and Remix takes care of fetching it on the server before the component renders.
Another game‑changer is the built‑in support for server actions. These let you mutate data directly from your UI without writing separate API endpoints, keeping your codebase DRY and your routes cohesive.
Finally, Remix’s focus on progressive enhancement means your app works even if JavaScript fails to load, giving you graceful degradation out of the box.
Getting Started: Project Scaffold
First, install the Remix CLI globally and create a new project using the official starter template. The CLI scaffolds a TypeScript‑ready structure, but you can switch to JavaScript if you prefer.
npm install -g create-remix
create-remix@latest my-remix-app
# Choose “Remix App Server” as the deployment target
cd my-remix-app
npm install
npm run dev
After the server starts, open http://localhost:3000 and you’ll see the default landing page. The folder layout is intentionally minimal: app/routes holds all your route modules, app/components for reusable UI, and app/models for data access logic.
Project Structure at a Glance
- app/routes – Each file corresponds to a URL path.
- app/components – Shared UI components (buttons, forms, etc.).
- app/models – Database or API abstractions.
- app/utils – Helper functions, e.g., authentication utilities.
Understanding this structure early helps you keep concerns separated as the app grows.
Routing & Data Loading
Remix routes are file‑based, similar to Next.js. A file named app/routes/posts/$postId.tsx automatically maps to /posts/:postId. The magic happens in the loader export, which runs on the server before the component renders.
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
export const loader = async ({ params }) => {
const post = await fetch(`https://jsonplaceholder.typicode.com/posts/${params.postId}`);
const data = await post.json();
return json(data);
};
export default function Post() {
const post = useLoaderData();
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
</article>
);
}
The loader runs on the server, fetches the post, and passes it to the component via useLoaderData. This eliminates the need for a separate useEffect call and guarantees the HTML is fully populated when it reaches the browser.
Nested Routes & Shared Loaders
Nested routes inherit data from parent loaders, making it trivial to build layouts that share common information (e.g., a user profile sidebar). Place a loader in app/routes/dashboard.tsx and all child routes under dashboard/ can access the same data via useRouteLoaderData.
Here’s a quick example of a parent loader providing user info:
export const loader = async ({ request }) => {
const user = await getUserFromSession(request);
return json({ user });
};
export default function DashboardLayout() {
const { user } = useRouteLoaderData("routes/dashboard");
return (
<div className="dashboard">
<aside>Welcome, {user.name}</aside>
<section><Outlet /></section>
</div>
);
}
Every nested route can now focus solely on its own concerns, while the layout handles authentication and UI scaffolding.
Server Actions: Mutations Made Simple
Traditional Remix apps required you to create separate API routes for POST, PUT, or DELETE operations. Remix 3.0 introduces server actions, which let you define mutation functions directly inside a route module.
import { redirect } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";
export const action = async ({ request }) => {
const formData = await request.formData();
const title = formData.get("title");
const body = formData.get("body");
await createPost({ title, body });
return redirect("/posts");
};
export default function NewPost() {
const error = useActionData();
return (
<Form method="post">
<label>Title<input name="title" /></label>
<label>Body<textarea name="body" /></label>
<button type="submit">Create</button>
{error && <p className="error">{error}</p>}
</Form>
);
}
The action runs on the server whenever the form is submitted. Remix automatically handles the request, runs the mutation, and then redirects or returns data. No extra API layer, no extra fetch calls.
Pro tip: Combine server actions with useTransition to display loading spinners without writing extra state logic.
Full‑Stack Data Handling with Prisma
While Remix works great with any data source, pairing it with Prisma gives you type‑safe database access and an intuitive query API. Below we’ll set up a simple SQLite database for a blog and integrate it into Remix loaders and actions.
# prisma/schema.prisma
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
generator client {
provider = "prisma-client-js"
}
model Post {
id Int @id @default(autoincrement())
title String
body String
createdAt DateTime @default(now())
}
Run npx prisma migrate dev --name init to generate the SQLite file and the client. Then create a app/models/post.server.ts file that encapsulates all DB interactions.
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
export async function getAllPosts() {
return prisma.post.findMany({ orderBy: { createdAt: "desc" } });
}
export async function getPostById(id: number) {
return prisma.post.findUnique({ where: { id } });
}
export async function createPost(data: { title: string; body: string }) {
return prisma.post.create({ data });
}
Now the Remix loader can call getAllPosts directly, guaranteeing that the data is fetched server‑side and serialized safely.
Integrating Prisma into a Route
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { getAllPosts } from "~/models/post.server";
export const loader = async () => {
const posts = await getAllPosts();
return json(posts);
};
export default function Posts() {
const posts = useLoaderData();
return (
<section>
<h2>All Posts</h2>
<ul>
{posts.map(p => (
<li key={p.id}>
<a href={`/posts/${p.id}`}>{p.title}</a>
</li>
))}
</ul>
</section>
);
}
This pattern scales beautifully: each route owns its data fetching logic, and the Prisma client stays centralized in the models layer.
Real‑World Use Case: E‑Commerce Checkout
Let’s walk through a miniature checkout flow that demonstrates Remix’s strengths: server‑side data validation, progressive enhancement, and instant UI feedback.
Step 1: Cart Loader
The cart is stored in a signed cookie. The loader reads the cookie, fetches product details from the DB, and returns a fully populated cart object.
import { json, createCookieSessionStorage } from "@remix-run/node";
import { getProductsByIds } from "~/models/product.server";
const sessionStorage = createCookieSessionStorage({
cookie: { name: "cart", secrets: ["s3cr3t"], sameSite: "lax" },
});
export const loader = async ({ request }) => {
const session = await sessionStorage.getSession(request.headers.get("Cookie"));
const cart = session.get("items") || [];
const productIds = cart.map(i => i.id);
const products = await getProductsByIds(productIds);
const enrichedCart = cart.map(item => ({
...item,
product: products.find(p => p.id === item.id),
}));
return json({ cart: enrichedCart });
};
Because the loader runs on the server, the HTML sent to the browser already contains the latest prices, preventing price‑tampering attacks.
Step 2: Checkout Action
The checkout action validates stock, creates an order, and clears the cart cookie—all in one atomic request.
import { redirect } from "@remix-run/node";
import { createOrder, decrementStock } from "~/models/order.server";
export const action = async ({ request }) => {
const session = await sessionStorage.getSession(request.headers.get("Cookie"));
const cart = session.get("items") || [];
// Basic validation
if (cart.length === 0) {
return json({ error: "Your cart is empty." }, { status: 400 });
}
// Simulate stock check
for (const item of cart) {
const product = await getProductById(item.id);
if (product.stock < item.quantity) {
return json({ error: `${product.name} is out of stock.` }, { status: 400 });
}
}
// Create order & update stock
const order = await createOrder({ items: cart, userId: session.get("userId") });
await Promise.all(cart.map(i => decrementStock(i.id, i.quantity)));
// Clear cart
session.unset("items");
return redirect(`/order/${order.id}`, {
headers: { "Set-Cookie": await sessionStorage.commitSession(session) },
});
};
Notice how the action returns a redirect with a fresh Set-Cookie header, ensuring the client’s cart is instantly cleared without a separate client‑side request.
Pro tip: Wrap critical sections (like stock decrement) in a database transaction to avoid race conditions under high traffic.
Step 3: Progressive Enhancement
If JavaScript is disabled, the form still submits to the server, and the user receives a full page reload with the order confirmation. If JavaScript is enabled, Remix’s built‑in useTransition provides a smooth “Submitting…” state without leaving the page.
Testing Remix Applications
Remix ships with a test runner that integrates nicely with Playwright for end‑to‑end tests and vitest for unit tests. A typical unit test for a loader looks like this:
import { loader } from "~/routes/posts/$postId";
import { createRequest } from "@remix-run/node";
test("loader returns 404 for missing post", async () => {
const request = createRequest(new URL("http://localhost/posts/999"));
const response = await loader({ params: { postId: "999" }, request });
expect(response.status).toBe(404);
});
For end‑to‑end testing, spin up the dev server and let Playwright interact with the UI just like a real user would.
import { test, expect } from "@playwright/test";
test("checkout flow works", async ({ page }) => {
await page.goto("/cart");
await page.click("text=Proceed to Checkout");
await page.waitForSelector("form");
await page.fill('input[name="address"]', "123 Remix St");
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/order\/\d+/);
});
These tests give you confidence that both server logic and client interactivity stay in sync as your app evolves.
Deploying Remix 3.0
Remix 3.0 supports a wide range of deployment targets: Vercel, Netlify, Cloudflare Workers, and traditional Node.js servers. The official remix build command produces a highly optimized bundle ready for any environment.
Deploy to Vercel (Zero‑Config)
- Push your repo to GitHub.
- Connect the repo in the Vercel dashboard and select “Remix” as the framework.
- Vercel automatically detects the
buildscript and creates a serverless function for each route.
Because Remix streams HTML from the server, you get fast First Contentful Paint (FCP) without any extra configuration.
Docker Deployment
If you prefer full control, containerize your app with a minimal Node image. Below is a simple Dockerfile that builds and runs the production bundle.
# Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/build ./build
COPY --from=builder /app/node_modules ./node_modules
EXPOSE 3000
CMD ["node", "build/index.js"]
Build with docker build -t my-remix-app . and run docker run -p 3000:3000 my-remix-app. Your app is now production‑ready and can be orchestrated with Kubernetes, ECS, or any container platform.
Performance Optimizations
Remix already does a lot of heavy lifting, but a few tweaks can push your app into the golden‑zone of web performance.
- Cache loader data. Use the
Cache-Controlheader in loaders