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
┌─────────────────────────────────────────────────────────┐ │ REMIX REQUEST FLOW │ │ │ │ Browser Request │ │ GET /products/42 │ │ │ │ │ ▼ │ │ ┌──────────────────────────────────────────────────┐ │ │ │ Remix Server │ │ │ │ │ │ │ │ 1. Match Route → /products/$id.tsx │ │ │ │ 2. Run loader() → Fetch data dari DB │ │ │ │ 3. Render React Component (SSR) │ │ │ │ 4. Return HTML + serialized data │ │ │ └──────────────────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌──────────────────────────────────────────────────┐ │ │ │ Browser │ │ │ │ │ │ │ │ 1. Tampilkan HTML langsung (fast TTFB) │ │ │ │ 2. Hydrate React di client │ │ │ │ 3. Handle navigations client-side │ │ │ └──────────────────────────────────────────────────┘ │ │ │ │ POST /products (Form Submit) │ │ │ │ │ ▼ │ │ ┌──────────────────────────────────────────────────┐ │ │ │ 1. action() → Proses form data │ │ │ │ 2. Validasi, simpan ke DB │ │ │ │ 3. redirect() atau return error │ │ │ └──────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────┘
Remix menggunakan React Router v6+ sebagai routing layer-nya. Pada tahun 2023, Remix bahkan merger dengan React Router — fitur-fitur Remix seperti loaders, actions, dan error boundaries sekarang tersedia juga di React Router v6.4+. Ini berarti konsep yang dipelajari di tutorial ini bisa langsung diterapkan di React Router modern.
2. Setup dan Instalasi
Membuat Proyek Remix Baru
# Buat proyek Remix baru npx create-remix@latest my-remix-app # Ikuti wizard: # ? Where would you like to create your app? ./my-remix-app # ? What type of app do you want to create? Remix App Server # ? Do you want me to run npm install? Yes # Masuk ke direktori cd my-remix-app # Jalankan development server npm run dev # Build untuk production npm run build # Jalankan production server npm start
Struktur Folder
my-remix-app/ ├── app/ │ ├── routes/ # File-based routes │ │ ├── _index.tsx # Halaman utama (/) │ │ ├── about.tsx # /about │ │ ├── products.tsx # /products (layout) │ │ ├── products._index.tsx # /products (index) │ │ ├── products.$id.tsx # /products/:id │ │ └── products.new.tsx # /products/new │ ├── components/ # Shared components │ ├── utils/ # Utility functions │ ├── root.tsx # Root layout & entry │ └── entry.client.tsx # Client entry point ├── public/ # Static files ├── .env # Environment variables ├── remix.config.js # Remix configuration ├── 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.
Struktur Nested Routes
app/routes/ ├── products.tsx ← Layout: /products │ (renders <Outlet /> untuk children) ├── products._index.tsx ← Index: /products ├── products.$id.tsx ← Detail: /products/:id ├── products.new.tsx ← Create: /products/new └── products.$id.edit.tsx ← Edit: /products/:id/edit Visual: ┌─────────────────────────────────────────┐ │ products.tsx (Layout) │ │ ┌─────────────────────────────────────┐ │ │ │ Sidebar: Category List │ │ │ ├─────────────────────────────────────┤ │ │ │ │ │ │ │ <Outlet /> │ │ │ │ ← Child route dirender di sini → │ │ │ │ │ │ │ └─────────────────────────────────────┘ │ └─────────────────────────────────────────┘
// app/routes/products.tsx — Layout route dengan shared loader
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData, Outlet, NavLink } from "@remix-run/react";
export async function loader({ request }: LoaderFunctionArgs) {
// Loader ini berjalan BERSAMAAN dengan child loader
const categories = await db.category.findMany({
orderBy: { name: 'asc' }
});
return json({ categories });
}
export default function ProductsLayout() {
const { categories } = useLoaderData<typeof loader>();
return (
<div className="products-layout">
{/* Sidebar — shared di semua halaman products */}
<aside className="sidebar">
<h2>Kategori</h2>
<nav>
<NavLink to="/products" end>
Semua Produk
</NavLink>
{categories.map((cat) => (
<NavLink
key={cat.id}
to={`/products?category=${cat.slug}`}
>
{cat.name}
</NavLink>
))}
</nav>
<NavLink to="/products/new" className="add-btn">
+ Tambah Produk
</NavLink>
</aside>
{/* Content area — child routes di-render di sini */}
<main className="products-content">
<Outlet />
</main>
</div>
);
}
Data Loading Paralel di Nested Routes
Request: GET /products/42 ┌─────────────────────────────────────────────────────┐ │ Remix Server (Paralel) │ │ │ │ ┌─────────────────────┐ ┌──────────────────────┐ │ │ │ products.tsx │ │ products.$id.tsx │ │ │ │ loader: │ │ loader: │ │ │ │ → fetch categories │ │ → fetch product #42 │ │ │ │ (berjalan paralel) │ │ (berjalan paralel) │ │ │ └─────────────────────┘ └──────────────────────┘ │ │ │ │ │ │ └────────┬───────────────┘ │ │ ▼ │ │ ┌──────────────────────────────────────────────┐ │ │ │ Render Layout + Child Component │ │ │ │ (semua data sudah tersedia) │ │ │ └──────────────────────────────────────────────┘ │ │ │ │ Total time: max(50ms, 100ms) = 100ms │ │ Bukan: 50ms + 100ms = 150ms │ └─────────────────────────────────────────────────────┘
8. Error Boundaries dan Catch Boundaries
Remix menyediakan sistem error handling yang robust melalui ErrorBoundary (untuk unexpected errors) dan CatchBoundary (untuk expected HTTP errors seperti 404).
// app/routes/products.$id.tsx
import {
useLoaderData, useRouteError, isRouteErrorResponse
} from "@remix-run/react";
export async function loader({ params }: LoaderFunctionArgs) {
const product = await db.product.findUnique({
where: { id: Number(params.id) }
});
if (!product) {
// throw Response → akan ditangkap oleh ErrorBoundary
throw new Response("Produk tidak ditemukan", { status: 404 });
}
return json({ product });
}
export default function ProductDetail() {
const { product } = useLoaderData<typeof loader>();
return <h1>{product.name}</h1>;
}
// Error Boundary — menangkap semua error di route ini
export function ErrorBoundary() {
const error = useRouteError();
// HTTP errors (dari throw Response)
if (isRouteErrorResponse(error)) {
if (error.status === 404) {
return (
<div className="error-page">
<h1>🔍 404 — Produk Tidak Ditemukan</h1>
<p>Produk yang Anda cari tidak tersedia.</p>
<a href="/products">← Kembali ke Daftar Produk</a>
</div>
);
}
return (
<div className="error-page">
<h1>❌ {error.status} {error.statusText}</h1>
<p>{error.data}</p>
</div>
);
}
// Unexpected errors (bug di kode)
return (
<div className="error-page">
<h1>💥 Terjadi Kesalahan</h1>
<p>{error instanceof Error ? error.message : "Unknown error"}</p>
<a href="/products">← Kembali ke Daftar Produk</a>
</div>
);
}
9. Teknik Advanced: Cookies, Sessions, dan Auth
Remix menyediakan abstraction untuk cookies dan sessions yang sangat mudah digunakan untuk autentikasi.
// app/utils/session.server.ts
import { createCookieSessionStorage, redirect } from "@remix-run/node";
// Konfigurasi session storage
const sessionSecret = process.env.SESSION_SECRET;
if (!sessionSecret) throw new Error("SESSION_SECRET harus diset");
const storage = createCookieSessionStorage({
cookie: {
name: "session",
secure: process.env.NODE_ENV === "production",
secrets: [sessionSecret],
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 30, // 30 hari
httpOnly: true,
},
});
// Helper functions
export async function createUserSession(userId: number, redirectTo: string) {
const session = await storage.getSession();
session.set("userId", userId);
return redirect(redirectTo, {
headers: {
"Set-Cookie": await storage.commitSession(session),
},
});
}
export async function getUserSession(request: Request) {
return storage.getSession(request.headers.get("Cookie"));
}
export async function getUserId(request: Request): Promise<number | null> {
const session = await getUserSession(request);
const userId = session.get("userId");
if (!userId || typeof userId !== "number") return null;
return userId;
}
export async function requireUserId(request: Request): Promise<number> {
const userId = await getUserId(request);
if (!userId) {
throw redirect("/login");
}
return userId;
}
export async function logout(request: Request) {
const session = await getUserSession(request);
return redirect("/login", {
headers: {
"Set-Cookie": await storage.destroySession(session),
},
});
}
// === Login route: app/routes/login.tsx ===
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const email = formData.get("email") as string;
const password = formData.get("password") as string;
const user = await login(email, password);
if (!user) {
return json({ error: "Email atau password salah" }, { status: 401 });
}
return createUserSession(user.id, "/dashboard");
}
10. Deployment Remix
Remix bisa di-deploy ke berbagai platform. Berikut beberapa opsi populer:
| Platform | Adapter | Perintah |
|---|---|---|
| Remix App Server | Built-in | npm run build && npm start |
| Vercel | @remix-run/vercel | Push ke GitHub, auto-deploy |
| Cloudflare Workers | @remix-run/cloudflare | wrangler deploy |
| Fly.io | Built-in server | fly deploy |
| Netlify | @remix-run/netlify | Push ke GitHub, auto-deploy |
| AWS Lambda | @remix-run/architect | Serverless deploy |
11. Remix vs Next.js
| Aspek | Remix | Next.js |
|---|---|---|
| Data Loading | Loaders per route (server-side) | getServerSideProps, React Server Components |
| Form Handling | Native HTML forms + actions | API routes + client-side fetch |
| Nested Routes | Paralel data loading built-in | Nested layouts (App Router) |
| Tanpa JS | Berfungsi tanpa JavaScript | Membutuhkan JavaScript |
| Error Handling | ErrorBoundary per route | error.js per segment |
| Rendering | Server-first, streaming SSR | SSG, SSR, ISR, Streaming |
| Learning Curve | 🟡 Sedang | 🟡 Sedang-Sulit |
| Deploy | Multi-platform (adapter-based) | Vercel-optimized, multi-platform |
12. Quiz: Uji Pemahamanmu!
Setelah membaca tutorial di atas, jawablah 5 pertanyaan berikut untuk menguji pemahamanmu tentang Remix: