1. Pengenalan Remix v2
Remix adalah full-stack web framework yang dibangun di atas React Router. Remix berfokus pada progressive enhancement — form bekerja tanpa JavaScript, dan JavaScript hanya meningkatkan pengalaman. Filosofi Remix: "Gunakan platform web (HTTP, HTML forms, browser) sebaik mungkin."
Di v2, Remix sepenuhnya terintegrasi dengan React Router v7, membawa fitur-fitur seperti file-based routing yang lebih fleksibel, streaming SSR, dan improved type inference.
Remix vs Framework Lain
| Fitur | Remix | Next.js | Astro |
|---|---|---|---|
| Data Loading | Loaders per route | Server Components | Content Collections |
| Mutations | Actions per route | Server Actions | API Routes |
| Nested Routes | ✅ Bawaan | ✅ App Router | ❌ Manual |
| Progressive Enhancement | ✅ Prioritas utama | ⚠️ Parsial | ✅ SSR-only |
| Framework | React | React | Multi-framework |
| Streaming | ✅ Deferred | ✅ Streaming RSC | ✅ SSR |
Remix sangat ideal untuk aplikasi yang memerlukan banyak form interactions — e-commerce, CMS, dashboard admin, SaaS apps. Progressive enhancement memastikan aplikasi tetap berfungsi bahkan tanpa JavaScript.
2. Project Setup
# Membuat project Remix baru npx create-remix@latest my-remix-app cd my-remix-app # Atau dengan template spesifik npx create-remix@latest my-app --template remix-run/remix/templates/remix # Jalankan development server npm run dev # Server berjalan di http://localhost:5173 # Struktur project: # my-remix-app/ # ├── app/ # │ ├── routes/ ← File-based routing # │ │ ├── _index.tsx → / # │ │ ├── about.tsx → /about # │ │ ├── blog.tsx → /blog (layout) # │ │ ├── blog._index.tsx→ /blog (index) # │ │ ├── blog.$slug.tsx → /blog/:slug # │ │ └── api.health.tsx → /api/health # │ ├── root.tsx ← Root layout # │ ├── entry.client.tsx ← Client entry # │ └── entry.server.tsx ← Server entry # ├── public/ ← Static assets # ├── remix.config.js # └── package.json
3. Loaders: Data Fetching
Loader adalah fungsi yang berjalan di server untuk mengambil data sebelum komponen di-render. Data dikirim ke komponen melalui hook useLoaderData().
// app/routes/blog._index.tsx
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData, Link } from "@remix-run/react";
// Loader: berjalan DI SERVER sebelum render
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const page = Number(url.searchParams.get("page") ?? "1");
const limit = 10;
// Ambil data dari database
const posts = await db.post.findMany({
where: { published: true },
orderBy: { createdAt: "desc" },
skip: (page - 1) * limit,
take: limit,
});
const total = await db.post.count({ where: { published: true } });
// json() helper untuk response dengan type inference
return json({
posts,
page,
totalPages: Math.ceil(total / limit),
});
}
// Komponen: menerima data dari loader
export default function BlogIndex() {
const { posts, page, totalPages } = useLoaderData<typeof loader>();
return (
<div className="blog-list">
<h1>Blog</h1>
{posts.map((post) => (
<article key={post.id}>
<h2><Link to={`/blog/${post.slug}`}>{post.title}</Link></h2>
<p>{post.excerpt}</p>
<time>{new Date(post.createdAt).toLocaleDateString("id-ID")}</time>
</article>
))}
<nav className="pagination">
{page > 1 && <Link to={`/blog?page=${page - 1}`}>← Prev</Link>}
<span>Halaman {page} dari {totalPages}</span>
{page < totalPages && <Link to={`/blog?page=${page + 1}`}>Next →</Link>}
</nav>
</div>
);
}
// app/routes/blog.$slug.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 { slug } = params;
if (!slug) {
throw new Response("Slug diperlukan", { status: 400 });
}
const post = await db.post.findUnique({
where: { slug, published: true },
include: { author: true, tags: true },
});
if (!post) {
// Remix akan mencari nearest ErrorBoundary
throw new Response("Artikel tidak ditemukan", { status: 404 });
}
return json({ post });
}
// Error boundary khusus route ini
export function ErrorBoundary() {
return (
<div className="error-page">
<h1>Artikel Tidak Ditemukan</h1>
<p>Artikel yang Anda cari tidak tersedia.</p>
<a href="/blog">← Kembali ke Blog</a>
</div>
);
}
export default function BlogPost() {
const { post } = useLoaderData<typeof loader>();
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
4. Actions: Form Handling
Action adalah fungsi yang berjalan di server saat form di-submit. Remix menggunakan HTML form standar — tanpa perlu onSubmit handler di React. Ini memastikan form tetap berfungsi tanpa JavaScript.
// app/routes/contact.tsx
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, useActionData, useNavigation } from "@remix-run/react";
// Loader: cek apakah user sudah login (opsional)
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request);
return json({
user: session.get("user"),
});
}
// Action: dipanggil saat form POST
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const name = formData.get("name") as string;
const email = formData.get("email") as string;
const message = formData.get("message") as string;
// Validasi server-side
const errors: Record<string, string> = {};
if (!name || name.length < 2) {
errors.name = "Nama harus minimal 2 karakter";
}
if (!email || !email.includes("@")) {
errors.email = "Email tidak valid";
}
if (!message || message.length < 10) {
errors.message = "Pesan harus minimal 10 karakter";
}
if (Object.keys(errors).length > 0) {
return json({ errors, values: { name, email, message } }, { status: 422 });
}
// Simpan ke database
await db.contact.create({
data: { name, email, message },
});
// Redirect setelah sukses (Post/Redirect/Get pattern)
return redirect("/contact?success=true");
}
export default function ContactPage() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
return (
<div className="contact-page">
<h1>Hubungi Kami</h1>
<Form method="post">
<div className="field">
<label htmlFor="name">Nama</label>
<input
id="name"
name="name"
type="text"
defaultValue={actionData?.values?.name}
required
/>
{actionData?.errors?.name && (
<p className="error">{actionData.errors.name}</p>
)}
</div>
<div className="field">
<label htmlFor="email">Email</label>
<input
id="email"
name="email"
type="email"
defaultValue={actionData?.values?.email}
required
/>
{actionData?.errors?.email && (
<p className="error">{actionData.errors.email}</p>
)}
</div>
<div className="field">
<label htmlFor="message">Pesan</label>
<textarea
id="message"
name="message"
rows={5}
defaultValue={actionData?.values?.message}
required
/>
{actionData?.errors?.message && (
<p className="error">{actionData.errors.message}</p>
)}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Mengirim..." : "Kirim Pesan"}
</button>
</Form>
</div>
);
}
5. Nested Routes
Nested routes adalah fitur paling powerful di Remix. Setiap route bisa memiliki layout sendiri, dan data dari semua route yang match di-fetch secara paralel.
// app/routes/dashboard.tsx — Parent/Layout route
import { Outlet, useLoaderData } from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
export async function loader({ request }: LoaderFunctionArgs) {
const user = await requireAuth(request); // Auth check
return json({ user });
}
export default function DashboardLayout() {
const { user } = useLoaderData<typeof loader>();
return (
<div className="dashboard">
<aside className="sidebar">
<h2>Dashboard</h2>
<nav>
<a href="/dashboard">Overview</a>
<a href="/dashboard/orders">Pesanan</a>
<a href="/dashboard/settings">Pengaturan</a>
</nav>
<p>Logged in as: {user.name}</p>
</aside>
<main className="dashboard-content">
{/* Child routes dirender di sini */}
<Outlet />
</main>
</div>
);
}
// app/routes/dashboard._index.tsx → /dashboard
export async function loader({ request }: LoaderFunctionArgs) {
const stats = await getDashboardStats();
return json({ stats });
}
export default function DashboardIndex() {
const { stats } = useLoaderData<typeof loader>();
return (
<div>
<h1>Overview</h1>
<div className="stats-grid">
<div className="stat">
<h3>{stats.totalOrders}</h3>
<p>Total Pesanan</p>
</div>
<div className="stat">
<h3>Rp {stats.revenue.toLocaleString()}</h3>
<p>Pendapatan</p>
</div>
</div>
</div>
);
}
// app/routes/dashboard.orders.tsx → /dashboard/orders
export async function loader({ request }: LoaderFunctionArgs) {
const orders = await db.order.findMany({ orderBy: { createdAt: "desc" } });
return json({ orders });
}
export default function OrdersPage() {
const { orders } = useLoaderData<typeof loader>();
return (
<div>
<h1>Pesanan</h1>
<table>
<thead><tr><th>ID</th><th>Customer</th><th>Total</th></tr></thead>
<tbody>
{orders.map((order) => (
<tr key={order.id}>
<td>{order.id}</td>
<td>{order.customerName}</td>
<td>Rp {order.total.toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
6. Enhanced Forms
Remix meningkatkan HTML forms standar tanpa menghilangkan fungsionalitas dasarnya. Saat JavaScript tersedia, form di-submit via fetch tanpa full page reload. Saat JavaScript tidak tersedia, form tetap berfungsi normal (progressive enhancement).
// Komponen yang menggunakan useFetcher untuk mutations di luar navigasi
import { useFetcher } from "@remix-run/react";
function DeleteButton({ itemId }: { itemId: string }) {
const fetcher = useFetcher();
const isDeleting = fetcher.state === "submitting";
return (
<fetcher.Form method="post" action="/api/items/delete">
<input type="hidden" name="itemId" value={itemId} />
<button
type="submit"
disabled={isDeleting}
onClick={(e) => {
if (!confirm("Yakin ingin menghapus?")) {
e.preventDefault();
}
}}
>
{isDeleting ? "Menghapus..." : "🗑️ Hapus"}
</button>
</fetcher.Form>
);
}
// Komponen Like Button tanpa navigasi
function LikeButton({ postId, likes }: { postId: string; likes: number }) {
const fetcher = useFetcher();
// Optimistic UI: tampilkan jumlah like terbaru
const currentLikes =
fetcher.formData?.get("likes") ?? likes;
return (
<fetcher.Form method="post">
<input type="hidden" name="postId" value={postId} />
<input type="hidden" name="likes" value={Number(currentLikes) + 1} />
<button type="submit" className="like-btn">
❤️ {currentLikes}
</button>
</fetcher.Form>
);
}
7. Streaming & Deferred Data
Remix mendukung streaming SSR yang memungkinkan halaman ditampilkan sebagian sambil data yang lambat masih di-fetch. Ini menggunakan defer() dan <Await>.
// app/routes/dashboard._index.tsx
import { defer } from "@remix-run/node";
import { Await, useLoaderData } from "@remix-run/react";
import { Suspense } from "react";
export async function loader({ request }: LoaderFunctionArgs) {
// Data kritis: HARUS selesai sebelum render
const user = await getUser(request);
// Data non-kritis: boleh streaming
const analyticsPromise = fetchAnalytics(user.id); // Lambat
const recommendationsPromise = fetchRecommendations(user.id); // Lambat
// defer(): data kritis di-embed, non-kritis di-stream
return defer({
user, // Langsung tersedia
analytics: analyticsPromise, // Streamed
recommendations: recommendationsPromise, // Streamed
});
}
export default function Dashboard() {
const { user, analytics, recommendations } = useLoaderData<typeof loader>();
return (
<div>
{/* Data langsung tersedia */}
<h1>Selamat datang, {user.name}!</h1>
{/* Analytics: loading state sampai data siap */}
<Suspense fallback={<div className="skeleton">Loading analytics...</div>}>
<Await resolve={analytics}>
{(data) => (
<div className="analytics">
<p>Page Views: {data.pageViews}</p>
<p>Conversions: {data.conversions}</p>
</div>
)}
</Await>
</Suspense>
{/* Recommendations: loading state */}
<Suspense fallback={<div className="skeleton">Loading...</div>}>
<Await resolve={recommendations}>
{(items) => (
<ul>
{items.map((item) => <li key={item.id}>{item.name}</li>)}
</ul>
)}
</Await>
</Suspense>
</div>
);
}
8. Cookie Sessions & Auth
Remix menyediakan API built-in untuk mengelola sessions via cookies, yang sangat penting untuk autentikasi.
// app/services/session.server.ts
import { createCookieSessionStorage, redirect } from "@remix-run/node";
import bcrypt from "bcryptjs";
// Konfigurasi cookie session
const sessionStorage = createCookieSessionStorage({
cookie: {
name: "__session",
httpOnly: true,
maxAge: 60 * 60 * 24 * 30, // 30 hari
path: "/",
sameSite: "lax",
secrets: [process.env.SESSION_SECRET!],
secure: process.env.NODE_ENV === "production",
},
});
// Mendapatkan session dari request
export async function getSession(request: Request) {
return sessionStorage.getSession(request.headers.get("Cookie"));
}
// Login
export async function login(email: string, password: string) {
const user = await db.user.findUnique({ where: { email } });
if (!user) return null;
const isValid = await bcrypt.compare(password, user.passwordHash);
if (!isValid) return null;
return { id: user.id, email: user.email, name: user.name };
}
// Membuat session setelah login
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),
},
});
}
// Mendapatkan user yang sedang login
export async function getUser(request: Request) {
const session = await getSession(request);
const userId = session.get("userId");
if (!userId) return null;
const user = await db.user.findUnique({
where: { id: userId },
select: { id: true, email: true, name: true },
});
return user;
}
// Require auth — redirect ke login jika belum login
export async function requireAuth(request: Request) {
const user = await getUser(request);
if (!user) {
const url = new URL(request.url);
throw redirect(`/login?redirectTo=${url.pathname}`);
}
return user;
}
// Logout
export async function logout(request: Request) {
const session = await getSession(request);
return redirect("/login", {
headers: {
"Set-Cookie": await sessionStorage.destroySession(session),
},
});
}
9. Best Practices
- Loader untuk semua data — tidak ada useEffect atau fetch di komponen
- Action untuk semua mutasi — gunakan Form, bukan fetch POST
- Progressive enhancement — pastikan form berfungsi tanpa JS
- useFetcher untuk mutations di luar navigasi (like, delete)
- defer() + Await untuk data non-kritis agar TTFB cepat
- Error boundaries — setiap route harus punya ErrorBoundary
- Meta function — SEO tags per route via meta export
- Cookie sessions — hindari JWT di klien untuk session
- Prisma/Drizzle — gunakan ORM untuk database akses
- Handle validation — validasi di action, bukan di komponen
10. Quiz: Uji Pemahamanmu!
Setelah membaca tutorial Remix v2, jawablah 5 pertanyaan berikut: