This episode covers the visual side of Remix: creating reusable React components, styling with CSS Modules, Tailwind, and styled-components, client-side interactivity, and how to optimize UI that's rendered on the server.

Routing and data are under your belt. Now we get into the most visible part: UI and component composition. This is where technical code turns into the experience users feel.
Remix is React, so all of your React component habits apply. What's different is discipline: because components are rendered on the server and then hydrated on the client, you must be aware of which code runs on the server, on the client, or both. This episode teaches that habit from the start.
Episode 6 covers reusable components, three styling approaches common in the Remix ecosystem, client-side interactivity, and how to optimize UI that's born on the server.
Components are functions that receive props and return UI elements. The key to reuse is separating repeated parts into small, single-responsibility components — like a button, card, or badge — then composing them in pages.
export function Kartu({ judul, children }) {
return (
<article>
<h2>{judul}</h2>
<div>{children}</div>
</article>
);
}Props determine component behavior; children enable nested composition. With this pattern, pages simply assemble blocks instead of repeating the same markup over and over.
Store components alongside the routes that use them, or in the app/components folder when used across pages. This pattern is covered in more depth in episode 18. For now, just remember the principle: one component, one file, one responsibility.
Remix supports CSS Modules with no extra configuration. Create a .module.css file, import it in a component, and Remix handles its scoping. Classes are scoped per component so there are no name collisions across files.
.tombol { background: #2563eb; color: white; padding: 8px 16px; }import styles from "./Button.module.css";
export function Tombol() {
return <button className={styles.tombol}>Klik</button>;
}Importing styles from a .module.css file produces an object that maps class names. Remix even optimizes CSS by only sending the files used by a given route.
Tailwind is the most popular choice in the Remix community. The integration is simple: install the Tailwind packages, run init, and point the content path at the app folder. Notably, in Vite-based Remix v3, the Tailwind plugin can be installed directly in vite.config.ts.
styled-components and other CSS-in-JS libraries also work, but there's a cost: new CSS is injected after hydration, which can cause a flash. If you choose CSS-in-JS, consider an approach that supports server rendering. For content-based applications, CSS Modules and Tailwind are generally a better fit.
Even though it's rendered on the server, a component becomes full React after hydration. Event handlers like onClick and state hooks like useState work normally on the client. Purely local interactivity — toggles, tabs, accordions — doesn't need a loader or action.
import { useState } from "react";
export default function Akordeon({ judul, isi }) {
const [terbuka, setTerbuka] = useState(false);
return (
<div>
<button onClick={() => setTerbuka(!terbuka)}>{judul}</button>
{terbuka ? <p>{isi}</p> : null}
</div>
);
}Local state like useState only exists on the client after hydration. UI that needs server data still uses loaders; purely visual interactions only need local state.
Use client rendering for parts that need real-time behavior: chat, timers, drag and drop. For everything else, let the server render. This principle keeps pages fast because the JavaScript sent is only for the interactions that actually exist.
Because Remix sends HTML that already contains content, first paint is very fast. The next main optimization is sending as little JavaScript as possible. Avoid writing manual fetch in components — that's the loader's job. Use Link prefetch to prepare the next page's data on hover.
import { Link } from "@remix-run/react";
export default function DaftarArtikel() {
return <Link prefetch="intent" to="/artikel/1">Baca artikel</Link>;
}The prefetch="intent" prop asks Remix to load the route's data when the cursor approaches the link. Clicking feels instant because the data is already cached on the client.
Finally, make a habit of checking your work with Lighthouse in the browser. Measure the time until the first content is visible, the amount of JavaScript sent, and the accessibility score. Episode 9 covers performance optimization in more depth, including caching.
Episode 6 closes the UI triangle: well-organized reusable components, styling with CSS Modules, Tailwind, or styled-components, client-side interactivity that's hydration-aware, and optimization so server-rendered UI stays light on the client.
The key takeaways:
In the next episode, episode 7, we'll discuss forms and validation — form handling with Remix's Form component, server-side validation with client feedback, rendering per-field errors, and progressive enhancement so forms keep working without JavaScript. Forms are where Remix truly shines.