Web Development

Remix: Full-Stack Web Framework

Tutorial lengkap belajar Remix framework — loaders, actions, nested routes, form handling, error boundaries, dan membangun full-stack web application dengan React

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 StandardsMemanfaatkan HTML forms, HTTP methods, dan browser APIs secara native
Nested RoutesSistem nested routes yang memungkinkan parallel data loading dan layout sharing
Server-Side RenderingSSR built-in — setiap halaman di-render di server untuk SEO dan performa
Progressive EnhancementForm berfungsi tanpa JavaScript — aplikasi tetap usable di semua kondisi
Zero-Bundle Data LoadingData di-load di server, bukan di client — mengurangi bundle size
Berbasis React RouterDibangun oleh pembuat React Router — routing yang matang dan powerful

Konsep Kunci Remix

Diagram: Request Flow di 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 dan React Router v6+

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

bash
# 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

text
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

typescript
// 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>&copy; 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/aboutHalaman about
blog/index.tsx/blogIndex halaman blog
blog.$slug.tsx/blog/:slugDynamic segment
products.tsx/products (layout)Layout route (nested)
products._index.tsx/productsIndex child route
products.$id.tsx/products/:idDynamic child route
products.new.tsx/products/newStatic child route
[sitemap.xml].tsx/sitemap.xmlEscape dari dot notation
dashboard.$.tsx/dashboard/*Catch-all route

Demo Routing

typescript
// 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

typescript
// 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

typescript
// 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

typescript
// 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 vs Action

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

typescript
// 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

typescript
// 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

text
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 →   │ │
│  │                                     │ │
│  └─────────────────────────────────────┘ │
└─────────────────────────────────────────┘
typescript
// 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

Diagram: Paralel Data Loading
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).

typescript
// 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.

typescript
// 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 ServerBuilt-innpm run build && npm start
Vercel@remix-run/vercelPush ke GitHub, auto-deploy
Cloudflare Workers@remix-run/cloudflarewrangler deploy
Fly.ioBuilt-in serverfly deploy
Netlify@remix-run/netlifyPush ke GitHub, auto-deploy
AWS Lambda@remix-run/architectServerless deploy

11. Remix vs Next.js

Aspek Remix Next.js
Data LoadingLoaders per route (server-side)getServerSideProps, React Server Components
Form HandlingNative HTML forms + actionsAPI routes + client-side fetch
Nested RoutesParalel data loading built-inNested layouts (App Router)
Tanpa JSBerfungsi tanpa JavaScriptMembutuhkan JavaScript
Error HandlingErrorBoundary per routeerror.js per segment
RenderingServer-first, streaming SSRSSG, SSR, ISR, Streaming
Learning Curve🟡 Sedang🟡 Sedang-Sulit
DeployMulti-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:

Pertanyaan 1: Apa fungsi dari loader() di Remix?

a) Mengirim email ke user
b) Mengambil data di server sebelum komponen di-render
c) Mengatur styling halaman
d) Menghandle form submission POST

Pertanyaan 2: Apa yang terjadi saat user me-submit form di Remix tanpa JavaScript aktif?

a) Form tidak bisa di-submit
b) Browser melakukan native form submission — Remix tetap berfungsi
c) Terjadi error di console
d) Form di-submit via AJAX

Pertanyaan 3: Apa keunggulan utama nested routes di Remix?

a) URL menjadi lebih pendek
b) Data loading bisa berjalan secara paralel di setiap level nesting
c) Tidak perlu menggunakan React Router
d) Tidak perlu SSR

Pertanyaan 4: Hook apa yang digunakan untuk mengakses data dari action() di Remix?

a) useLoaderData()
b) useFetchData()
c) useActionData()
d) useRouteData()

Pertanyaan 5: Bagaimana cara melakukan mutation tanpa navigasi di Remix?

a) Menggunakan useEffect
b) Menggunakan useFetcher()
c) Menggunakan fetch() biasa
d) Tidak bisa dilakukan
🔍 Zoom
100%
🎨 Tema