MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr πŸ”₯ tagline

Hey there πŸ‘‹ I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 β€” present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 β€” Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms β€” earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Remix: A Full-Stack Web Framework

Explore Remix: nested routing, loaders, actions, and progressive enhancement.

RemixReactFull-StackFrontend

By MinhVo

Introduction

Remix is a full-stack web framework built on React that takes a fundamentally different approach to data loading, form handling, and server rendering. Created by Ryan Florence and Michael Jackson (the creators of React Router), Remix leverages web standards like HTTP, HTML forms, and browser navigation instead of building complex client-side state management systems. The result is applications that are faster, more resilient, and easier to build than traditional single-page applications.

The key insight behind Remix is that the web platform already has excellent primitives for data mutation (forms), navigation (links), and error handling (HTTP status codes). Rather than replacing these primitives with JavaScript-heavy alternatives, Remix enhances them. A Remix form submission works even when JavaScript fails to load. A Remix loader works during server rendering and client-side navigation. Error boundaries catch failures at the route level, showing meaningful UI instead of a blank screen.

Since joining Shopify in 2022, Remix has become the foundation for Shopify's Hydrogen framework and is increasingly adopted by companies building complex web applications. The merger of Remix and React Router v7 (announced in 2024) brings Remix's server-side capabilities to every React Router user, making these patterns accessible to millions of developers. This guide covers Remix's architecture, practical implementation patterns, and the philosophy behind its web-standards-first approach.

Web Framework Architecture

Understanding Remix Architecture

Nested Routing and Layout

Remix uses file-based nested routing where each route file exports a React component and optionally a loader (for data) and action (for mutations). The route hierarchy creates a layout tree:

app/
β”œβ”€β”€ root.tsx              # Root layout wrapping everything
β”œβ”€β”€ routes/
β”‚   β”œβ”€β”€ _index.tsx        # / route
β”‚   β”œβ”€β”€ posts.tsx         # /posts layout with Outlet
β”‚   β”œβ”€β”€ posts._index.tsx  # /posts index
β”‚   β”œβ”€β”€ posts.$id.tsx     # /posts/:id
β”‚   └── posts.new.tsx     # /posts/new
└── entry.server.tsx
// routes/posts.tsx - Layout route with nested data
import { Outlet, useLoaderData, Link } from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
 
export async function loader({ request }: LoaderFunctionArgs) {
  const posts = await db.post.findMany({
    select: { id: true, title: true, slug: true },
    orderBy: { createdAt: "desc" },
  });
  return json({ posts });
}
 
export default function PostsLayout() {
  const { posts } = useLoaderData<typeof loader>();
 
  return (
    <div className="posts-layout">
      <nav className="posts-sidebar">
        <h2>Posts</h2>
        <ul>
          {posts.map((post) => (
            <li key={post.id}>
              <Link to={`/posts/${post.slug}`}>{post.title}</Link>
            </li>
          ))}
        </ul>
        <Link to="/posts/new">New Post</Link>
      </nav>
      <main className="posts-content">
        <Outlet /> {/* Child route renders here */}
      </main>
    </div>
  );
}

Loaders: Server-Side Data Fetching

Every route can export a loader function that runs on the server before the component renders. Loaders have access to the full request object, databases, APIs, and any server-side resource. The data flows to the component via useLoaderData:

// routes/posts.$id.tsx
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData, useRouteError, isRouteErrorResponse } from "@remix-run/react";
 
export async function loader({ params }: LoaderFunctionArgs) {
  const post = await db.post.findUnique({
    where: { slug: params.id },
    include: { author: true, comments: true },
  });
 
  if (!post) {
    throw new Response("Post not found", { status: 404 });
  }
 
  return json({ post });
}
 
export default function PostPage() {
  const { post } = useLoaderData<typeof loader>();
 
  return (
    <article>
      <h1>{post.title}</h1>
      <p>By {post.author.name}</p>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
      <section>
        <h2>Comments ({post.comments.length})</h2>
        {post.comments.map((comment) => (
          <div key={comment.id}>{comment.body}</div>
        ))}
      </section>
    </article>
  );
}
 
export function ErrorBoundary() {
  const error = useRouteError();
 
  if (isRouteErrorResponse(error)) {
    return <div>Error: {error.status} {error.statusText}</div>;
  }
  return <div>Something went wrong.</div>;
}

Actions: Handling Mutations

Actions handle form submissions. When a form posts to a route, Remix calls the route's action function with the parsed form data. After the action completes, all loaders on the page re-run automatically, ensuring the UI stays in sync with the server state:

// routes/posts.new.tsx
import type { ActionFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, useActionData, useNavigation } from "@remix-run/react";
 
export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const title = formData.get("title") as string;
  const content = formData.get("content") as string;
  const slug = title.toLowerCase().replace(/\s+/g, "-");
 
  const errors: Record<string, string> = {};
  if (!title || title.length < 3) errors.title = "Title must be at least 3 characters";
  if (!content || content.length < 50) errors.content = "Content must be at least 50 characters";
 
  if (Object.keys(errors).length > 0) {
    return json({ errors, values: { title, content } }, { status: 400 });
  }
 
  const post = await db.post.create({
    data: { title, content, slug, authorId: "user-1" },
  });
 
  return redirect(`/posts/${post.slug}`);
}
 
export default function NewPost() {
  const actionData = useActionData<typeof action>();
  const navigation = useNavigation();
  const isSubmitting = navigation.state === "submitting";
 
  return (
    <Form method="post">
      <div>
        <label htmlFor="title">Title</label>
        <input
          id="title"
          name="title"
          defaultValue={actionData?.values?.title}
          required
        />
        {actionData?.errors?.title && <span>{actionData.errors.title}</span>}
      </div>
      <div>
        <label htmlFor="content">Content</label>
        <textarea
          id="content"
          name="content"
          defaultValue={actionData?.values?.content}
          rows={12}
          required
        />
        {actionData?.errors?.content && <span>{actionData.errors.content}</span>}
      </div>
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? "Creating..." : "Create Post"}
      </button>
    </Form>
  );
}

Data Flow Architecture

Architecture and Design Patterns

The Progressive Enhancement Pattern

Remix forms work without JavaScript. When JavaScript is loaded, Remix intercepts form submissions for client-side navigation. When JavaScript is unavailable, the browser handles the form submission natively. This means every Remix form is progressively enhanced by default:

// This form works with and without JavaScript
<Form method="post">
  <input name="email" type="email" required />
  <button type="submit">Subscribe</button>
</Form>
 
// Without JS: browser submits form, server processes, full page reload
// With JS: Remix intercepts, submits via fetch, updates UI in-place

The Resource Route Pattern

Routes don't have to render UI. A route that exports only a loader or action (no default component) is called a resource route. These are used for API endpoints, file downloads, and data feeds:

// routes/api.posts.tsx - Resource route (no UI component)
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
 
export async function loader({ request }: LoaderFunctionArgs) {
  const url = new URL(request.url);
  const page = Number(url.searchParams.get("page") || "1");
  const limit = Number(url.searchParams.get("limit") || "10");
 
  const posts = await db.post.findMany({
    skip: (page - 1) * limit,
    take: limit,
    orderBy: { createdAt: "desc" },
  });
 
  return json(posts, {
    headers: {
      "Cache-Control": "public, max-age=60",
    },
  });
}

The Optimistic UI Pattern

Remix's useNavigation and useFetcher hooks enable optimistic UI patterns where the interface updates before the server confirms the mutation:

import { useFetcher } from "@remix-run/react";
 
function LikeButton({ postId, likes }: { postId: string; likes: number }) {
  const fetcher = useFetcher();
 
  // Optimistic: show the new like count immediately
  const optimisticLikes = fetcher.formData
    ? likes + 1
    : likes;
 
  return (
    <fetcher.Form method="post" action={`/api/posts/${postId}/like`}>
      <button type="submit">
        ❀️ {optimisticLikes}
      </button>
    </fetcher.Form>
  );
}

The Cache and Revalidation Pattern

Remix revalidates all loaders after every navigation and mutation. This keeps the UI always in sync with the server. For expensive queries, implement caching strategies:

export async function loader({ request }: LoaderFunctionArgs) {
  const cached = await redis.get("homepage-data");
  if (cached) {
    return json(JSON.parse(cached), {
      headers: { "X-Cache": "HIT" },
    });
  }
 
  const data = await fetchExpensiveData();
  await redis.setex("homepage-data", 300, JSON.stringify(data));
 
  return json(data, {
    headers: {
      "Cache-Control": "public, max-age=300",
      "X-Cache": "MISS",
    },
  });
}

Step-by-Step Implementation

Project Setup

npx create-remix@latest my-app
cd my-app
// package.json scripts
{
  "scripts": {
    "dev": "remix dev",
    "build": "remix build",
    "start": "remix-serve ./build/server/index.js",
    "typecheck": "tsc"
  }
}

Building a Blog with Authentication

// app/services/auth.server.ts
import { createCookieSessionStorage } from "@remix-run/node";
import bcrypt from "bcryptjs";
 
const sessionStorage = createCookieSessionStorage({
  cookie: {
    name: "__session",
    httpOnly: true,
    maxAge: 60 * 60 * 24 * 30,
    path: "/",
    sameSite: "lax",
    secrets: [process.env.SESSION_SECRET!],
    secure: process.env.NODE_ENV === "production",
  },
});
 
export async function createUserSession(userId: string, redirectTo: string) {
  const session = await sessionStorage.getSession();
  session.set("userId", userId);
  return redirect(redirectTo, {
    headers: { "Set-Cookie": await sessionStorage.commitSession(session) },
  });
}
 
export async function getUserSession(request: Request) {
  return sessionStorage.getSession(request.headers.get("Cookie"));
}
 
export async function requireUserId(request: Request): Promise<string> {
  const session = await getUserSession(request);
  const userId = session.get("userId");
  if (!userId || typeof userId !== "string") {
    throw redirect("/login");
  }
  return userId;
}

Server-Side Validation with Zod

import { z } from "zod";
import { json } from "@remix-run/node";
 
const PostSchema = z.object({
  title: z.string().min(3).max(200),
  content: z.string().min(50),
  published: z.boolean().default(false),
});
 
export async function action({ request }: ActionFunctionArgs) {
  const userId = await requireUserId(request);
  const formData = await request.formData();
  const rawData = Object.fromEntries(formData);
 
  const result = PostSchema.safeParse(rawData);
  if (!result.success) {
    const errors = result.error.flatten().fieldErrors;
    return json({ errors, values: rawData }, { status: 400 });
  }
 
  const post = await db.post.create({
    data: { ...result.data, authorId: userId },
  });
 
  return redirect(`/posts/${post.slug}`);
}

Data Relationships and Serialization

// Remix serializes data to JSON, so Date objects need conversion
export async function loader({ params }: LoaderFunctionArgs) {
  const post = await db.post.findUnique({
    where: { slug: params.id },
    include: {
      author: { select: { name: true, avatar: true } },
      comments: {
        include: { author: { select: { name: true } } },
        orderBy: { createdAt: "desc" },
      },
    },
  });
 
  if (!post) throw new Response("Not Found", { status: 404 });
 
  return json({
    post: {
      ...post,
      createdAt: post.createdAt.toISOString(),
      comments: post.comments.map((c) => ({
        ...c,
        createdAt: c.createdAt.toISOString(),
      })),
    },
  });
}

Modern Web Development

Real-World Use Cases

Shopify Hydrogen

Shopify's headless commerce framework is built on Remix. It uses Remix loaders to fetch product data, collections, and cart state from Shopify's Storefront API. The nested routing model maps naturally to e-commerce page hierarchies: collections layout > collection page > product page > cart.

Enterprise Dashboards

Remix's server-side rendering and progressive enhancement make it suitable for enterprise applications where reliability is critical. Forms work even if JavaScript fails to load, error boundaries prevent cascading failures, and the server-side data model simplifies data access patterns.

Content Management Systems

Remix's form handling and server-side processing make it a natural fit for CMS-like applications. Content creation, editing, and publishing workflows map directly to Remix actions. The progressive enhancement ensures content editors can always create and save content.

Best Practices

  1. Keep loaders focused β€” Each loader should fetch only the data its component needs. Remix parallelizes loaders during navigation, so smaller, focused loaders load faster than one large loader that fetches everything.

  2. Use json helper consistently β€” Always return responses using the json() helper. It sets the correct Content-Type header and provides type inference with typeof loader on the client.

  3. Throw Response objects for errors β€” Use throw new Response("Not Found", { status: 404 }) in loaders to trigger error boundaries. Don't return error statesβ€”throw them.

  4. Validate form data on the server β€” Use Zod or a similar library to validate form data in actions. Return structured error objects that the form component can display inline.

  5. Use resource routes for API endpoints β€” Routes without a default component serve as API endpoints. Use these for downloads, webhooks, and AJAX endpoints that don't render HTML.

  6. Leverage useFetcher for non-navigation mutations β€” Use useFetcher for mutations that don't change the URL (liking a post, toggling a setting). Use Form for mutations that should navigate (creating a new post, logging in).

  7. Implement proper error boundaries β€” Export ErrorBoundary from every route. This catches loader errors, action errors, and rendering errors, preventing the entire app from crashing.

  8. Use shouldRevalidate to optimize β€” Export a shouldRevalidate function to prevent unnecessary loader re-runs. This is useful for expensive loaders that depend only on specific params.

Common Pitfalls and Solutions

PitfallImpactSolution
Returning non-serializable data from loadersRuntime errorSerialize Date, Map, Set to JSON-compatible types
Missing error boundariesApp crashes on loader errorsExport ErrorBoundary from every route
Client-side data fetching in componentsDouble data fetching, flickerUse loaders exclusively for route data
Not handling form validation errorsSilent failuresReturn error objects from actions, display in form
Large bundle from heavy dependenciesSlow initial loadUse dynamic imports and code splitting
Forgetting to handle loading statesConfusing UI during submissionsUse useNavigation/useFetcher states

Comparison with Other Frameworks

FeatureRemixNext.jsSvelteKitAstro
Data LoadingLoaders (server)getServerSideProps / RSCload functiongetStaticPaths
MutationsActions (server)API Routes / Server Actionsform actionsN/A (static)
Progressive EnhancementBuilt-inManualBuilt-inN/A
Nested RoutingBuilt-inApp RouterBuilt-inFile-based
Error BoundariesPer-routePer-segmentPer-layoutN/A
StreamingDeferred dataSuspenseStreamingIslands

Architecture Decision Records

When evaluating architectural choices for your project, documenting your decision-making process through Architecture Decision Records (ADRs) provides invaluable context for future team members and stakeholders. Each ADR captures the context, decision, and consequences of a specific architectural choice.

Creating Effective ADRs

An ADR should include the date of the decision, the status (proposed, accepted, deprecated, or superseded), the context that motivated the decision, the decision itself, and the expected consequences both positive and negative. This structured approach ensures that decisions are traceable and reversible when circumstances change.

# ADR-001: Choose React for Frontend Framework
 
## Status: Accepted
 
## Context
We need a frontend framework that supports component-based architecture,
has a large ecosystem, and provides good TypeScript support.
 
## Decision
We will use React 18+ with TypeScript for all new frontend projects.
 
## Consequences
- Large talent pool available for hiring
- Mature ecosystem with extensive third-party libraries
- Strong TypeScript integration
- Requires additional libraries for routing and state management

Decision Matrix for Technology Selection

Create a weighted decision matrix when comparing multiple options. List your evaluation criteria (performance, learning curve, ecosystem maturity, community support, long-term viability) and assign weights based on your project priorities. Score each option on a scale of 1-5 for each criterion, then calculate weighted totals.

This systematic approach removes emotion from technology decisions and provides a defensible rationale when stakeholders question your choices. Document the matrix alongside your ADR so future teams understand not just what was chosen, but why alternatives were rejected.

Reversibility and Migration Paths

Every architectural decision should include a migration path in case the decision needs to be reversed. Consider the cost of changing course at six months, twelve months, and two years. Decisions with low reversal costs can be made more aggressively, while irreversible decisions warrant extended evaluation periods and proof-of-concept implementations.

For example, choosing a CSS-in-JS library has a relatively low reversal cost since styles can be migrated incrementally component by component. However, choosing a database technology has a high reversal cost due to data migration complexity and potential schema changes throughout the codebase.

Community Resources and Further Learning

The technology landscape evolves rapidly, making continuous learning essential for maintaining expertise. Building a systematic approach to staying current with developments in your technology stack ensures you can leverage new features and avoid deprecated patterns.

Curated Learning Pathways

Rather than consuming content randomly, create structured learning pathways aligned with your current projects and career goals. Start with official documentation and specification documents, which provide the most accurate and comprehensive information. Follow this with hands-on tutorials and workshops that reinforce concepts through practical application.

Technical blogs from framework maintainers and core team members often provide deeper insights into design decisions and upcoming features. Subscribe to the official blogs of your primary frameworks and libraries to stay ahead of breaking changes and deprecation timelines.

Contributing to Open Source

Contributing to open-source projects in your technology stack provides unparalleled learning opportunities. Start with documentation improvements and bug reports, then progress to fixing small issues tagged as "good first issue" in your favorite projects. This direct engagement with maintainers and the codebase accelerates your understanding far beyond what passive learning can achieve.

# Setting up for contribution
git clone https://github.com/project/repository.git
cd repository
git checkout -b fix/issue-description
 
# Run the project's contribution setup
npm run setup:dev
npm run test  # Ensure tests pass before making changes
 
# Make your changes, then run the full test suite
npm run test:full
npm run lint
npm run build
 
# Submit your contribution
git add -A
git commit -m "fix: description of the fix
 
Closes #1234"
git push origin fix/issue-description

Building a Technical Knowledge Base

Maintain a personal knowledge base that captures insights, solutions, and patterns you discover during your work. Tools like Obsidian, Notion, or even a simple Markdown repository can serve as an external memory that grows more valuable over time.

Organize your notes by topic rather than chronologically, and include code examples, links to relevant documentation, and explanations of why certain approaches work better than others. When you encounter a particularly insightful article or conference talk, write a summary that captures the key takeaways and how they apply to your current projects.

Follow key conferences and their published talks to stay informed about emerging patterns and best practices. Many conferences publish recorded talks on YouTube within weeks of the event, making world-class technical content freely accessible.

Join relevant Discord servers, Slack communities, and forums where practitioners discuss real-world challenges and solutions. These communities provide early warning about emerging issues and access to collective wisdom that isn't available through formal documentation.

Mentorship and Knowledge Sharing

Teaching others is one of the most effective ways to deepen your own understanding. Consider writing technical blog posts, giving talks at local meetups, or mentoring junior developers. The process of explaining concepts to others forces you to organize your knowledge and identify gaps in your understanding.

Pair programming sessions with colleagues of different experience levels create mutual learning opportunities. Senior developers gain fresh perspectives on problems they've solved the same way for years, while junior developers benefit from exposure to production-grade thinking and decision-making processes.

Conclusion

Remix brings web standards to the forefront of React development. By building on HTTP, HTML forms, and browser navigation instead of replacing them, Remix creates applications that are faster, more resilient, and more accessible than traditional single-page applications.

Key takeaways:

  1. Loaders fetch data server-side before rendering, eliminating waterfalls and loading spinners
  2. Actions handle form mutations server-side, with automatic revalidation keeping UI in sync
  3. Progressive enhancement ensures every form and interaction works without JavaScript
  4. Nested routing creates natural layout hierarchies with parallel data loading
  5. Resource routes serve as API endpoints without rendering UI
  6. Error boundaries at every route level prevent cascading failures

Start by creating a Remix project with npx create-remix@latest, defining your routes, and implementing loaders and actions. The web-standards-first approach means skills you learn in Remix transfer directly to the web platform itself.