Remix Complete Guide | Full Stack React Framework with Loaders & Actions

Remix Complete Guide | Full Stack React Framework with Loaders & Actions

이 글의 핵심

Remix is a full-stack React framework built on Web Standards. It simplifies data fetching with loaders, form handling with actions, and works without JavaScript through progressive enhancement.

Introduction

Remix is a full-stack web framework built by the creators of React Router. It embraces web standards and progressive enhancement, making your apps fast, resilient, and accessible.

Why Remix?

Next.js approach:

// Separate data fetching methods
export async function getServerSideProps() { ... }
export async function getStaticProps() { ... }
// API routes in separate files

Remix approach:

// Data fetching in the same file
export async function loader() { ... }
// Form handling in the same file  
export async function action() { ... }

1. Getting Started

Create Project

npx create-remix@latest my-app
cd my-app
npm run dev

Choose deployment target:

  • Remix App Server (recommended for learning)
  • Vercel
  • Cloudflare Pages
  • Fly.io

Project Structure

my-app/
├── app/
│   ├── routes/
│   │   ├── _index.tsx        # Home page (/)
│   │   ├── about.tsx          # /about
│   │   └── posts.$id.tsx      # /posts/:id
│   ├── root.tsx               # Root layout
│   └── entry.client.tsx       # Client entry
├── public/
└── remix.config.js

2. Loaders: Server-Side Data Fetching

Basic Loader

// app/routes/posts.tsx
import type { LoaderFunctionArgs } from '@remix-run/node';
import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';

interface Post {
  id: number;
  title: string;
  content: string;
}

export async function loader() {
  const response = await fetch('https://api.example.com/posts');
  const posts: Post[] = await response.json();
  
  return json({ posts });
}

export default function Posts() {
  const { posts } = useLoaderData<typeof loader>();
  
  return (
    <div>
      <h1>Posts</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <a href={`/posts/${post.id}`}>{post.title}</a>
          </li>
        ))}
      </ul>
    </div>
  );
}

Loader with Parameters

// app/routes/posts.$id.tsx
import type { LoaderFunctionArgs } from '@remix-run/node';
import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';

export async function loader({ params }: LoaderFunctionArgs) {
  const response = await fetch(`https://api.example.com/posts/${params.id}`);
  
  if (!response.ok) {
    throw new Response('Not Found', { status: 404 });
  }
  
  const post = await response.json();
  return json({ post });
}

export default function Post() {
  const { post } = useLoaderData<typeof loader>();
  
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  );
}

3. Actions: Form Handling

Basic Action

// app/routes/posts.new.tsx
import type { ActionFunctionArgs } from '@remix-run/node';
import { json, redirect } from '@remix-run/node';
import { Form, useActionData } from '@remix-run/react';

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const title = formData.get('title');
  const content = formData.get('content');
  
  // Validation
  if (!title || !content) {
    return json({ error: 'All fields are required' }, { status: 400 });
  }
  
  // Save to database
  const response = await fetch('https://api.example.com/posts', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ title, content }),
  });
  
  const post = await response.json();
  
  // Redirect to new post
  return redirect(`/posts/${post.id}`);
}

export default function NewPost() {
  const actionData = useActionData<typeof action>();
  
  return (
    <Form method="post">
      <h1>Create New Post</h1>
      
      {actionData?.error && (
        <div className="error">{actionData.error}</div>
      )}
      
      <div>
        <label htmlFor="title">Title</label>
        <input id="title" name="title" type="text" required />
      </div>
      
      <div>
        <label htmlFor="content">Content</label>
        <textarea id="content" name="content" required />
      </div>
      
      <button type="submit">Create Post</button>
    </Form>
  );
}

Update with Optimistic UI

import { useFetcher } from '@remix-run/react';

export function LikeButton({ postId, likes }: { postId: number; likes: number }) {
  const fetcher = useFetcher();
  
  // Optimistic update
  const displayLikes = fetcher.formData
    ? likes + 1
    : likes;
  
  return (
    <fetcher.Form method="post" action={`/posts/${postId}/like`}>
      <button type="submit" disabled={fetcher.state !== 'idle'}>
        ❤️ {displayLikes} {fetcher.state !== 'idle' && '...'}
      </button>
    </fetcher.Form>
  );
}

4. Nested Routes

File-Based Routing

app/routes/
├── _index.tsx                 # /
├── about.tsx                  # /about
├── blog.tsx                   # /blog (layout)
├── blog._index.tsx            # /blog (index)
├── blog.$slug.tsx             # /blog/:slug
└── blog.new.tsx               # /blog/new

Nested Layout

// app/routes/blog.tsx (Parent Layout)
import { Outlet } from '@remix-run/react';

export default function BlogLayout() {
  return (
    <div className="blog-container">
      <nav>
        <a href="/blog">All Posts</a>
        <a href="/blog/new">New Post</a>
      </nav>
      
      <main>
        <Outlet /> {/* Child routes render here */}
      </main>
    </div>
  );
}
// app/routes/blog._index.tsx (Child)
export default function BlogIndex() {
  return <h1>Blog Home</h1>;
}

5. Error Handling

Error Boundary

// app/routes/posts.$id.tsx
import { useRouteError, isRouteErrorResponse } from '@remix-run/react';

export async function loader({ params }: LoaderFunctionArgs) {
  const post = await getPost(params.id);
  
  if (!post) {
    throw new Response('Not Found', { status: 404 });
  }
  
  return json({ post });
}

export function ErrorBoundary() {
  const error = useRouteError();
  
  if (isRouteErrorResponse(error)) {
    return (
      <div>
        <h1>{error.status} {error.statusText}</h1>
        <p>{error.data}</p>
      </div>
    );
  }
  
  return (
    <div>
      <h1>Error</h1>
      <p>Something went wrong!</p>
    </div>
  );
}

export default function Post() {
  // Component code
}

6. Form Validation

With Zod

npm install zod
import { z } from 'zod';

const PostSchema = z.object({
  title: z.string().min(1, 'Title is required').max(100),
  content: z.string().min(10, 'Content must be at least 10 characters'),
});

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  
  const result = PostSchema.safeParse({
    title: formData.get('title'),
    content: formData.get('content'),
  });
  
  if (!result.success) {
    return json({ 
      errors: result.error.flatten().fieldErrors 
    }, { status: 400 });
  }
  
  // Save validated data
  const post = await createPost(result.data);
  return redirect(`/posts/${post.id}`);
}

7. Database Integration

Prisma Example

// app/routes/users.tsx
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

export async function loader() {
  const users = await prisma.user.findMany({
    select: {
      id: true,
      name: true,
      email: true,
    },
  });
  
  return json({ users });
}

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  
  const user = await prisma.user.create({
    data: {
      name: formData.get('name') as string,
      email: formData.get('email') as string,
    },
  });
  
  return redirect(`/users/${user.id}`);
}

8. Authentication

Session Management

// app/utils/session.server.ts
import { createCookieSessionStorage, redirect } from '@remix-run/node';

const sessionStorage = createCookieSessionStorage({
  cookie: {
    name: '__session',
    httpOnly: true,
    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 getUserId(request: Request): Promise<string | null> {
  const session = await sessionStorage.getSession(
    request.headers.get('Cookie')
  );
  return session.get('userId');
}

export async function requireUserId(request: Request) {
  const userId = await getUserId(request);
  if (!userId) {
    throw redirect('/login');
  }
  return userId;
}

Protected Route:

// app/routes/dashboard.tsx
export async function loader({ request }: LoaderFunctionArgs) {
  const userId = await requireUserId(request);
  const user = await getUserById(userId);
  
  return json({ user });
}

9. Optimistic UI

useOptimistic Hook

'use client';

import { useFetcher } from '@remix-run/react';
import { useOptimistic } from 'react';

export function TodoList({ todos }: { todos: Todo[] }) {
  const fetcher = useFetcher();
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(
    todos,
    (state, newTodo: Todo) => [...state, newTodo]
  );
  
  return (
    <div>
      <ul>
        {optimisticTodos.map((todo) => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>
      
      <fetcher.Form
        method="post"
        onSubmit={(e) => {
          const formData = new FormData(e.currentTarget);
          addOptimisticTodo({
            id: Date.now(),
            title: formData.get('title') as string,
          });
        }}
      >
        <input name="title" />
        <button type="submit">Add</button>
      </fetcher.Form>
    </div>
  );
}

10. File Uploads

import { unstable_parseMultipartFormData } from '@remix-run/node';
import { writeFile } from 'fs/promises';
import path from 'path';

export async function action({ request }: ActionFunctionArgs) {
  const uploadHandler = async ({ name, data }: any) => {
    if (name !== 'file') return undefined;
    
    const chunks = [];
    for await (const chunk of data) {
      chunks.push(chunk);
    }
    const buffer = Buffer.concat(chunks);
    
    const filename = `${Date.now()}-${Math.random()}.png`;
    const filepath = path.join('public/uploads', filename);
    await writeFile(filepath, buffer);
    
    return `/uploads/${filename}`;
  };
  
  const formData = await unstable_parseMultipartFormData(request, uploadHandler);
  const fileUrl = formData.get('file');
  
  return json({ fileUrl });
}

11. Meta Tags & SEO

import type { MetaFunction } from '@remix-run/node';

export const meta: MetaFunction<typeof loader> = ({ data }) => {
  return [
    { title: data.post.title },
    { name: 'description', content: data.post.excerpt },
    { property: 'og:title', content: data.post.title },
    { property: 'og:description', content: data.post.excerpt },
    { property: 'og:image', content: data.post.image },
  ];
};

export async function loader({ params }: LoaderFunctionArgs) {
  const post = await getPost(params.id);
  return json({ post });
}

12. Resource Routes (API Endpoints)

// app/routes/api.posts.ts
import type { LoaderFunctionArgs } from '@remix-run/node';
import { json } from '@remix-run/node';

export async function loader({ request }: LoaderFunctionArgs) {
  const posts = await getPosts();
  return json({ posts });
}

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const post = await createPost(formData);
  return json({ post }, { status: 201 });
}

13. Prefetching

import { Link } from '@remix-run/react';

export default function PostList({ posts }: { posts: Post[] }) {
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>
          {/* Prefetch on hover */}
          <Link to={`/posts/${post.id}`} prefetch="intent">
            {post.title}
          </Link>
        </li>
      ))}
    </ul>
  );
}

Prefetch options:

  • none: No prefetch
  • intent: Prefetch on hover/focus
  • render: Prefetch when link renders
  • viewport: Prefetch when in viewport

14. Environment Variables

// .env
DATABASE_URL=postgresql://...
SESSION_SECRET=your-secret-here
// app/utils/env.server.ts
export const env = {
  DATABASE_URL: process.env.DATABASE_URL!,
  SESSION_SECRET: process.env.SESSION_SECRET!,
};

15. Best Practices

1. Use Type-Safe Loaders

export async function loader() {
  return json({ message: 'Hello' } as const);
}

// TypeScript knows the exact shape
const { message } = useLoaderData<typeof loader>();

2. Separate Concerns

app/
├── routes/
├── models/          # Database queries
├── utils/           # Utilities
└── services/        # Business logic

3. Handle Loading States

import { useNavigation } from '@remix-run/react';

export default function App() {
  const navigation = useNavigation();
  const isLoading = navigation.state === 'loading';
  
  return (
    <div>
      {isLoading && <div>Loading...</div>}
      <Outlet />
    </div>
  );
}

4. Use fetcher for Non-Navigation Actions

const fetcher = useFetcher();

// Update without navigation
<fetcher.Form method="post" action="/api/update">
  <input name="field" />
  <button type="submit">Update</button>
</fetcher.Form>

16. Deployment

Cloudflare Pages

npm install @remix-run/cloudflare
// remix.config.js
module.exports = {
  serverModuleFormat: 'esm',
  server: './server.ts',
  serverBuildPath: 'functions/[[path]].js',
  serverPlatform: 'neutral',
};

Vercel

npm install @remix-run/vercel
// remix.config.js
module.exports = {
  server: '@remix-run/vercel',
};

Summary

Remix simplifies full-stack React development:

  • Loaders handle server-side data fetching
  • Actions process forms without API routes
  • Progressive Enhancement works without JavaScript
  • Nested Routes enable complex layouts
  • Web Standards based on fetch, FormData, Response

Key Takeaways:

  1. Co-locate data fetching with components (loaders)
  2. Handle forms declaratively with actions
  3. Use nested routes for complex UIs
  4. Embrace web standards for resilience
  5. Progressive enhancement by default

Next Steps:

  • Build a full app with Next.js 15
  • Learn React 18 features
  • Master TanStack Query for client-side

Resources: