1. Pengenalan Remix
Remix adalah full-stack web framework yang dibangun di atas React, dikembangkan oleh Ryan Florence dan Michael Jackson (pencipta React Router). Remix berfokus pada web standards, progressive enhancement, dan memanfaatkan sepenuhnya kemampuan HTTP — seperti form submissions, cookies, dan caching — tanpa perlu client-side JavaScript yang berlebihan.
Remix menekankan filosofi bahwa server harus melakukan pekerjaan berat (data loading, mutations), sedangkan client hanya menangani interaksi UI. Hasilnya, aplikasi Remix lebih cepat, lebih accessible, dan berfungsi bahkan tanpa JavaScript di client.
Mengapa Memilih Remix?
| Keunggulan | Penjelasan |
|---|---|
| Web Standards | Memanfaatkan HTML forms, HTTP methods, dan browser APIs secara native |
| Nested Routes | Sistem nested routes yang memungkinkan parallel data loading dan layout sharing |
| Server-Side Rendering | SSR built-in — setiap halaman di-render di server untuk SEO dan performa |
| Progressive Enhancement | Form berfungsi tanpa JavaScript — aplikasi tetap usable di semua kondisi |
| Zero-Bundle Data Loading | Data di-load di server, bukan di client — mengurangi bundle size |
| Berbasis React Router | Dibangun oleh pembuat React Router — routing yang matang dan powerful |
Konsep Kunci Remix
/"my-remix-app/"/
app/
routes/
/"_index.tsx"/
/"about.tsx"/
/"products.tsx"/
/"products._index.tsx"/
/"products.$id.tsx"/
/"products.new.tsx"/
/"components/"/
utils/
/"root.tsx"/
/"entry.client.tsx"/
public/
.env
/"remix.config.js"/
/"package.json"/
/"tsconfig.json"/
/"README.md"/
Root Layout
// app/root.tsx — Entry point dan root layout
import {
Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration,
useRouteError, isRouteErrorResponse
} from "@remix-run/react";
import type { LinksFunction } from "@remix-run/node";
import styles from "~/styles/global.css";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: styles },
];
export default function App() {
return (
<html lang="id">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
<header>
<nav>
<a href="/">Beranda</a>
<a href="/products">Produk</a>
<a href="/about">Tentang</a>
</nav>
</header>
<main>
{/* Outlet: tempat child routes di-render */}
<Outlet />
</main>
<footer>
<p>© 2026 My Remix App</p>
</footer>
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}
// Error boundary di root level
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<div className="error-page">
<h1>{error.status} {error.statusText}</h1>
<p>{error.data}</p>
</div>
);
}
return (
<div className="error-page">
<h1>Terjadi Kesalahan</h1>
<p>{error instanceof Error ? error.message : 'Unknown error'}</p>
</div>
);
}
3. File-Based Routing
Remix menggunakan sistem file-based routing di mana setiap file di folder app/routes/ otomatis menjadi route. Penamaan file menentukan URL path.
Konvensi Penamaan Route
| File | URL Path | Keterangan |
|---|---|---|
_index.tsx | / | Halaman index/root |
about.tsx | /about | Halaman about |
blog/index.tsx | /blog | Index halaman blog |
blog.$slug.tsx | /blog/:slug | Dynamic segment |
products.tsx | /products (layout) | Layout route (nested) |
products._index.tsx | /products | Index child route |
products.$id.tsx | /products/:id | Dynamic child route |
products.new.tsx | /products/new | Static child route |
[sitemap.xml].tsx | /sitemap.xml | Escape dari dot notation |
dashboard.$.tsx | /dashboard/* | Catch-all route |
Demo Routing
// app/routes/_index.tsx — Halaman utama
import type { MetaFunction } from "@remix-run/node";
import { Link } from "@remix-run/react";
export const meta: MetaFunction = () => {
return [
{ title: "Beranda - My Remix App" },
{ name: "description", content: "Selamat datang di aplikasi Remix kami" },
];
};
export default function Index() {
return (
<div className="home-page">
<h1>Selamat Datang di My Remix App</h1>
<p>Aplikasi full-stack yang dibangun dengan Remix</p>
<nav>
<Link to="/products">Lihat Produk</Link>
<Link to="/about">Tentang Kami</Link>
</nav>
</div>
);
}
// app/routes/about.tsx — Halaman About
export default function About() {
return (
<div>
<h1>Tentang Kami</h1>
<p>Kami adalah perusahaan teknologi yang berfokus pada inovasi.</p>
</div>
);
}
4. Loaders: Mengambil Data
Loader adalah fungsi yang berjalan di server untuk mengambil data sebelum komponen di-render. Data dari loader tersedia di component melalui hook useLoaderData(). Loader berjalan pada setiap request — baik itu initial page load (SSR) maupun client-side navigation.
Loader Dasar
// app/routes/products._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 — bisa akses DB langsung!
export async function loader({ request }: LoaderFunctionArgs) {
// Contoh: Fetch dari database
const products = await db.product.findMany({
orderBy: { createdAt: 'desc' },
take: 20
});
// Contoh: Parse search params
const url = new URL(request.url);
const searchQuery = url.searchParams.get("q") || "";
const category = url.searchParams.get("category") || "";
// Filter produk
let filtered = products;
if (searchQuery) {
filtered = filtered.filter(p =>
p.name.toLowerCase().includes(searchQuery.toLowerCase())
);
}
if (category) {
filtered = filtered.filter(p => p.category === category);
}
// json() helper untuk mengembalikan response
return json({
products: filtered,
searchQuery,
category,
totalProducts: products.length
});
}
export default function ProductsPage() {
// useLoaderData() — mengakses data dari loader
const { products, searchQuery, category, totalProducts } =
useLoaderData<typeof loader>();
return (
<div className="products-page">
<h1>Daftar Produk ({totalProducts})</h1>
{/* Search form — menggunakan GET method */}
<Form method="get">
<input
type="text"
name="q"
defaultValue={searchQuery}
placeholder="Cari produk..."
/>
<select name="category" defaultValue={category}>
<option value="">Semua Kategori</option>
<option value="elektronik">Elektronik</option>
<option value="fashion">Fashion</option>
<option value="makanan">Makanan</option>
</select>
<button type="submit">Cari</button>
</Form>
<div className="product-grid">
{products.map((product) => (
<div key={product.id} className="product-card">
<h2>{product.name}</h2>
<p>Rp {product.price.toLocaleString('id-ID')}</p>
<p>Stok: {product.stock}</p>
<Link to={`/products/${product.id}`}>Detail →</Link>
</div>
))}
</div>
</div>
);
}
Loader dengan Dynamic Params
// app/routes/products.$id.tsx
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData, Link } from "@remix-run/react";
export async function loader({ params }: LoaderFunctionArgs) {
// params.id — dari URL /products/:id
const productId = Number(params.id);
if (isNaN(productId)) {
throw new Response("ID Produk tidak valid", { status: 400 });
}
const product = await db.product.findUnique({
where: { id: productId },
include: { reviews: true, category: true }
});
if (!product) {
throw new Response("Produk tidak ditemukan", { status: 404 });
}
return json({ product });
}
export default function ProductDetail() {
const { product } = useLoaderData<typeof loader>();
return (
<div className="product-detail">
<Link to="/products">← Kembali ke Produk</Link>
<h1>{product.name}</h1>
<span className="category">{product.category.name}</span>
<p className="price">
Rp {product.price.toLocaleString('id-ID')}
</p>
<p className="stock">Stok: {product.stock}</p>
<p className="description">{product.description}</p>
<h2>Ulasan ({product.reviews.length})</h2>
{product.reviews.map((review) => (
<div key={review.id} className="review">
<strong>{review.author}</strong>
<span>{'⭐'.repeat(review.rating)}</span>
<p>{review.content}</p>
</div>
))}
</div>
);
}
5. Actions: Memproses Data
Action adalah fungsi yang berjalan di server untuk memproses data form submission (POST, PUT, PATCH, DELETE). Ini adalah cara Remix menangani mutations — mengubah data di server. Data dari action tersedia di component melalui hook useActionData().
Action untuk Membuat Data Baru
// app/routes/products.new.tsx
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { useActionData, Form, useNavigation } from "@remix-run/react";
// Action: Memproses form submission POST
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
// Ambil data dari form
const name = formData.get("name") as string;
const price = Number(formData.get("price"));
const description = formData.get("description") as string;
const category = formData.get("category") as string;
const stock = Number(formData.get("stock"));
// === VALIDASI SERVER-SIDE ===
const errors: Record<string, string> = {};
if (!name || name.length < 3) {
errors.name = "Nama produk minimal 3 karakter";
}
if (!price || price <= 0) {
errors.price = "Harga harus lebih dari 0";
}
if (!description) {
errors.description = "Deskripsi wajib diisi";
}
if (!category) {
errors.category = "Pilih kategori";
}
if (!stock || stock < 0) {
errors.stock = "Stok tidak boleh negatif";
}
// Jika ada error, kembalikan ke component
if (Object.keys(errors).length > 0) {
return json(
{ errors, values: { name, price, description, category, stock } },
{ status: 400 }
);
}
// Simpan ke database
try {
const product = await db.product.create({
data: { name, price, description, category, stock }
});
// Redirect ke halaman produk setelah berhasil
return redirect(`/products/${product.id}`);
} catch (error) {
return json(
{ errors: { form: "Gagal menyimpan produk. Silakan coba lagi." } },
{ status: 500 }
);
}
}
export default function NewProduct() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
// Status form submission
const isSubmitting = navigation.state === "submitting";
return (
<div className="new-product-page">
<h1>Tambah Produk Baru</h1>
<Form method="post">
<div className="form-group">
<label htmlFor="name">Nama Produk</label>
<input
id="name"
name="name"
defaultValue={actionData?.values?.name}
required
/>
{actionData?.errors?.name && (
<p className="error">{actionData.errors.name}</p>
)}
</div>
<div className="form-group">
<label htmlFor="price">Harga (Rp)</label>
<input
id="price"
name="price"
type="number"
defaultValue={actionData?.values?.price}
required
/>
{actionData?.errors?.price && (
<p className="error">{actionData.errors.price}</p>
)}
</div>
<div className="form-group">
<label htmlFor="description">Deskripsi</label>
<textarea
id="description"
name="description"
defaultValue={actionData?.values?.description}
required
/>
{actionData?.errors?.description && (
<p className="error">{actionData.errors.description}</p>
)}
</div>
<div className="form-group">
<label htmlFor="category">Kategori</label>
<select id="category" name="category" defaultValue={actionData?.values?.category}>
<option value="">Pilih kategori</option>
<option value="elektronik">Elektronik</option>
<option value="fashion">Fashion</option>
<option value="makanan">Makanan</option>
</select>
{actionData?.errors?.category && (
<p className="error">{actionData.errors.category}</p>
)}
</div>
<div className="form-group">
<label htmlFor="stock">Stok</label>
<input
id="stock"
name="stock"
type="number"
defaultValue={actionData?.values?.stock}
required
/>
</div>
{actionData?.errors?.form && (
<p className="error">{actionData.errors.form}</p>
)}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Menyimpan...' : 'Simpan Produk'}
</button>
</Form>
</div>
);
}
Loader = GET request, mengambil data, berjalan setiap kali halaman diakses. Action = POST/PUT/DELETE request, memproses/mengubah data, berjalan saat form di-submit. Keduanya berjalan di server dan mengembalikan data ke client.
6. Form Handling Tanpa JavaScript
Salah satu keunggulan terbesar Remix adalah form yang berfungsi tanpa JavaScript. Saat JavaScript belum aktif, browser menggunakan native form submission. Saat JavaScript sudah aktif, Remix intercept untuk pengalaman SPA yang lebih cepat.
Delete dengan Form
// components/DeleteProductButton.tsx
import { Form } from "@remix-run/react";
export function DeleteProductButton({ productId }: { productId: number }) {
return (
<Form method="post" action={`/products/${productId}/delete`}>
<button
type="submit"
onClick={(e) => {
if (!confirm("Yakin ingin menghapus produk ini?")) {
e.preventDefault();
}
}}
>
🗑️ Hapus Produk
</button>
</Form>
);
}
// app/routes/products.$id.delete.tsx
export async function action({ params }: ActionFunctionArgs) {
const productId = Number(params.id);
await db.product.delete({ where: { id: productId } });
return redirect("/products");
}
useFetcher — Mutations Tanpa Navigasi
// components/LikeButton.tsx
import { useFetcher } from "@remix-run/react";
export function LikeButton({ productId, initialLikes }: {
productId: number;
initialLikes: number;
}) {
const fetcher = useFetcher();
// Optimistic UI: langsung tampilkan perubahan
const likes = fetcher.formData
? Number(fetcher.formData.get("likes")) + 1
: initialLikes;
return (
<fetcher.Form method="post" action={`/api/products/${productId}/like`}>
<input type="hidden" name="likes" value={initialLikes} />
<button
type="submit"
disabled={fetcher.state !== "idle"}
className={fetcher.state !== "idle" ? "liking" : ""}
>
❤️ {likes} Suka
</button>
</fetcher.Form>
);
}
// components/AddToCartButton.tsx
export function AddToCartButton({ productId }: { productId: number }) {
const fetcher = useFetcher();
return (
<fetcher.Form method="post" action="/api/cart">
<input type="hidden" name="productId" value={productId} />
<input type="hidden" name="quantity" value="1" />
<button type="submit">
{fetcher.state === "submitting"
? "⏳ Menambahkan..."
: fetcher.state === "loading"
? "✅ Ditambahkan!"
: "🛒 Tambah ke Keranjang"}
</button>
</fetcher.Form>
);
}
7. Nested Routes dan Layouts
Nested Routes adalah fitur unggulan Remix yang memungkinkan komponen layout (seperti sidebar, tab navigation) di-render bersama dengan konten halaman. Setiap level nesting bisa memiliki loader-nya sendiri yang berjalan secara paralel.