The landscape of current React full-stack frameworks is pretty much dominated by Next.js. But I never quite liked it, and always felt it’s too complex for its own good. So recently I’ve started to use Remix.
If you were here in 2020, you might remember that the launch of Remix was quite the event. But back then it was a paid solution. And it had a pretty hefty price tag as well, with $250 for an “indie” license and a whopping $1000 for an enterprise one (as Nader Dabit said back then, I really don’t remember). This proven to be rather ineffective strategy, because in October 2021 Remix went fully open-source, and one year after that, it got bought by Shopify.
But enough of that history lesson!
What is a full-stack React framework?
As mentioned, Remix is a full-stack framework. This means, it executes both
backend and frontend code. This is used mostly to render the React application
on the server and send it to the consumer, rather than render everything on the
client. It is both faster and more SEO-friendly, but comes with a cost. Most
notably, it is no longer a collection of static files, but a living application
that needs a server. It also adds a bit of overhead and limitations, like
useEffect
not being active on the server or having to check what environment
we’re using. And this is where Remix come in.
Getting started with Remix
As will all modern, mature tools, Remix can be initialized with a single command:
~ npx create-remix@latest
That’s it, if you want the basics. And for now, we do want basics. It will ask you a few things, like the project name (and directory), whether to initialize a new git repo and whether to install dependencies. After all that is done, all that’s left is to enter the directory named after your project and run
~ npm run dev
It runs the whole application in a development mode with hot reloading, Tailwind and Vite config ready to go. Basically, you can start writing your app right now.
But, before you do, let’s walk through our new project.
Entry files
First, you will notice two files: entry.server
and entry.client
. This is
Remix’ way of differentiating whether given file should be ran on the server, on
the client, or (if no suffix is passed) on both.
Opening entry.client
does not give us any gasps, it’s just a hydration file.
React uses “hydration” mechanism to virtualize the document the server rendered and attach itself to it, so all the JavaScript can be executed against it.
entry.server
is a bit more interesting, but it’s more of a scaffolding. It has
bot detection and can decide what to render. Function handleRequest
checks the
visitor and if it decides that it’s a crawler or other bot, it runs
handleBotRequest
, otherwise handleBrowserRequest
. Fun fact is, both
functions are the same for now. IDE even warns us about it. But these functions
can be expanded and we can, for example, block all bots, reduce its traffic, or
serve different content. But I would advice against doing that last thing, as it
might end in search engines sandboxing you, if they find you serve different
content to its crawlers and to regular users.
Then we have the root
file, which is, again, a rather simple React component.
It is responsible for the default layout the app will have, including <head>
and <body>
elements. One thing to notice is, it includes Meta
, Links
and
Scripts
components. These are responsible for injecting all the data our
routes will provide, for example different meta tags or style links.
Routes
Okay, so let’s dive into the meat of Remix – the routing. And by default, this
system is quite baffling to me, to be frank. It uses flat structure with file
name as route params. For example, if we want to have /hello
that will
redirect us to /hello/{name}
, this is the structure:
/routes
|- hello.$name.tsx
|- hello._index.tsx
If we want to go deeper, let’s say, we want to have categories and
subcategories, it will be even funnier: categories.$category.$subcategory.tsx
.
Thankfully, there is a rather simple solution to this: Remix Flat Routes
package. To install it, we need to go with:
npm install -D remix-flat-routes
And now the documentation is a bit outdated, as it still refers the
remix.config.js
. What we actually need to do, is to update vite.config.ts
:
import { vitePlugin as remix } from "@remix-run/dev";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
import { flatRoutes } from "remix-flat-routes";
declare module "@remix-run/node" {
interface Future {
v3_singleFetch: true;
}
}
export default defineConfig({
plugins: [
remix({
routes: (defineRoutes) => flatRoutes("routes", defineRoutes),
future: {
v3_fetcherPersist: true,
v3_relativeSplatPath: true,
v3_throwAbortReason: true,
v3_singleFetch: true,
v3_lazyRouteDiscovery: true,
},
}),
tsconfigPaths(),
],
});
And now we can use nested routes:
/routes
|- /hello2+
|-- $name.tsx
|-- _index.tsx
What’s cool is that we can also create layouts for every route. Let’s create a
_layout.tsx
file:
import { useOutlet } from "@remix-run/react";
export default function Layout() {
const outlet = useOutlet();
return <main style={{ padding: "1rem", background: "#ddd" }}>{outlet}</main>;
}
And now every route under /hello2/
will have a padding and a grey background.
We can also define layouts in a flat way, but quite honestly, it looks terrible:
/routes
|- hello._hello.$name.tsx // This is the main subpage
|- hello._hello.tsx // This is the layout file
|- hello._index.tsx // This is the redirection
I know that the nested is not perfect, with the +
as a suffix, but it still
beats the dotted filenames. But, if you don’t feel like it, you can stay with
the basic, default structure. The choice is all yours.
Data fetching
Alright, let’s get to the good stuff! Everyone knows that fetching data on the server, serving and revalidating it is the hard part. But, quite surprisingly, Remix makes it really easy, even if a bit limited.
To start, let’s create a new route, and call it jokes
:
// ./routes/jokes.tsx
export default function Jokes() {
return <div>My funny jokes</div>;
}
I am using a flat route because it won’t have any other routes or layouts.
Right, so there is an API that serves the best jokes on the Internet: Official Joke API. So let’s fetch ten random bits and render it.
The first thing is to understand how data is loaded in Remix. It does not load within components, but within routes. So every route can load data, but if you want a component to use it, you must pass it through as a prop. Basically, components cannot fetch on their own, if you want to have the data on the server. This is the biggest flaw of Remix if you ask me. I assume that when React Server Components will be finalized with React 19, Remix (or, by then, React Router 7) will utilize it.
Okay, so let’s move on. Fetching the data is actually quite simple, as it boils
down to exporting an async loader
function:
export async function loader() {}
If we run this though, we’ll get an error. Loader always has to return something. And the best would be, to return JSON. Luckily, Remix has you covered:
export async function loader() {
const response = await fetch(
"https://official-joke-api.appspot.com/jokes/random/10",
);
const jokes = await response.json();
return json({ jokes });
}
And let’s use this data. For that, we’ll need the useLoaderData
hook:
export default function Jokes() {
const data = useLoaderData<typeof loader>();
return <pre>{JSON.stringify(data.jokes)}</pre>;
}
Note the typeof loader
type. It shows that we have an object with jokes
in
it. Unfortunately, this is not magic and jokes
is defined as any
. But we can
type it ourselves.
interface Joke {
id: number;
type: string;
setup: string;
punchline: string;
}
export async function loader() {
const response = await fetch(
"https://official-joke-api.appspot.com/jokes/random/10",
);
const jokes = (await response.json()) as Joke[];
return json({ jokes });
}
Adding as
is often seen as a hack, so we can do something else, namely pass
the type to the generic useLoaderData
:
const data = useLoaderData<{ jokes: Joke[] }>();
This forces us to define everything that loader
returns, but we get it fully
typed, instead of assigned to any
. Whatever way you chose, both will give you
proper results.
Error handling
Okay, that’s perfect, but what if our server breaks? Or the response will
change? loader
allows us to catch errors using try/catch
statement:
export async function loader() {
try {
const response = await fetch(
"https://official-joke-api.appspot.com/jokes/random/10",
);
const jokes = await response.json();
return json({ jokes });
} catch (error) {
return json({ error: (error as Error).message }, 500);
}
}
All we need to do now is to handle it in the render:
export default function Jokes() {
const data = useLoaderData<{ jokes: Joke[] } | { error: string }>();
if ("error" in data) {
return <div style={{ background: "red" }}>{data.error}</div>;
}
return (
<ul>
{data.jokes.map((joke) => (
<li key={joke.id}>
<strong>{joke.setup}</strong>
<p>{joke.punchline}</p>
</li>
))}
</ul>
);
}
And that’s it!
Sending data
Cool, so fetching is done. How about sending some forms? Remix handles this as well, and in a similarly seamless way.
First, we need to export action
async function. It has one parameter (object)
of ActionFunctionArgs
type, which then has request
item, which we can
utilize:
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
console.log(formData.get("name"));
return null;
}
Hey, you can say, FormData is browser-only! Well, yes, but Remix patches it, so you don’t have to worry about where your code will be executed.
I realize this sounds like I am being sponsored by Remix, but I am not, I am just truly happy with how many problems it solves.
And the form that we need has to be imported from Remix:
import { Form } from "@remix-run/react";
export default function FormPage() {
return (
<Form method="post">
<label>
Name:
<input type="text" name="name" />
</label>
<button type="submit">Submit</button>
</Form>
);
}
Okay, let’s try to submit this! And you can see in the network inspector that it actually does a fetch in browser, but points to our page. That’s how Remix handles web transactions that are bound to the server, so your API is never seen. If you will do a client-side fetch, your API will be exposed.
Of course we can now take this form data, send it somewhere and return something! So let’s make a simple function that will capitalize our input.
function toUpper(str: string) {
return str.toUpperCase();
}
That’s some senior code, everything SOLID.
Right, so our action now returns:
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const name = formData.get("name");
if (typeof name === "string") {
return json({ name: toUpper(name) });
}
return json({ error: "Name is required" }, { status: 400 });
}
and in our main function, we now use useActionData
, which works very similarly
to useLoaderData
:
const data = useActionData<{ name: string } | { error: string }>();
So let’s now edit the render, and if nothing has been received, we’ll display the form, otherwise we’ll resolve to either result or error:
export default function FormPage() {
const data = useActionData<{ name: string } | { error: string }>();
if (!data) {
return (
<Form method="post">
<label>
Uppercasify this name:
<input type="text" name="name" />
</label>
<button type="submit">Submit</button>
</Form>
);
}
if ("error" in data) {
return <div>{data?.error || "Error!"}</div>;
}
if ("name" in data) {
return <div>Uppercased: {data.name}</div>;
}
}
That’s all for the Remix intro. As you can see, the framework has rather little footprint and is very easy to use. The only thing I am worried about is the fact that it will soon be merged with (or rather, absorbed by) React Router. Creators promise easy migration, but as someone who lived through React Router migrations before, I will stack on ani-stress balls a week before I sit to it.
Join me next week, where I’ll show you my Remix template with Atomic Design and feature-based structure.