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 prefetchintent: Prefetch on hover/focusrender: Prefetch when link rendersviewport: 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:
- Co-locate data fetching with components (loaders)
- Handle forms declaratively with actions
- Use nested routes for complex UIs
- Embrace web standards for resilience
- 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: