Web Development

Remix v2 Deep Dive

Eksplorasi mendalam Remix v2 — loaders, actions, nested routes, HTML forms yang ditingkatkan, streaming SSR, cookie sessions, dan semua fitur yang membuat Remix menjadi framework full-stack terbaik untuk React.

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

FiturRemixNext.jsAstro
Data LoadingLoaders per routeServer ComponentsContent Collections
MutationsActions per routeServer ActionsAPI Routes
Nested Routes✅ Bawaan✅ App Router❌ Manual
Progressive Enhancement✅ Prioritas utama⚠️ Parsial✅ SSR-only
FrameworkReactReactMulti-framework
Streaming✅ Deferred✅ Streaming RSC✅ SSR
💡 Tips

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

Shell — Membuat Project Remix
# 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().

TypeScript — Basic Loader
// 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>
  );
}
TypeScript — Loader dengan Error Handling
// 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.

TypeScript — Action dengan Form
// 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.

TypeScript — Nested Routes dengan Outlet
// 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>
  );
}
TypeScript — Child Routes
// 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).

TypeScript — Delete dengan useFetcher
// 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>.

TypeScript — Streaming dengan defer
// 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.

TypeScript — Cookie Session Server
// 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

✅ Remix 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:

Pertanyaan 1: Apa fungsi utama loader dalam Remix?

a) Meng-handle form submission
b) Mengambil data di server sebelum render
c) Meng-render komponen React
d) Mengelola state klien

Pertanyaan 2: Kapan action dijalankan dalam Remix?

a) Saat halaman di-load
b) Saat form di-submit (POST/PUT/DELETE)
c) Saat komponen mount
d) Saat route berubah

Pertanyaan 3: Apa yang digunakan untuk streaming data non-kritis di Remix?

a) useLoaderData
b) defer() dan Await
c) useFetcher
d) Suspense langsung

Pertanyaan 4: Hook apa yang digunakan untuk mutations tanpa navigasi?

a) useLoaderData
b) useNavigate
c) useFetcher
d) useActionData

Pertanyaan 5: Komponen apa yang digunakan untuk merender child routes?

a) Children
b) Render
c) Outlet
d) Slot
🔍 Zoom
100%
🎨 Tema