First look at Remix

I can't recall the last time there was that much buzz around a front-end technology as it is today, at Remix launch. The atmosphere of common excitement is contagious and the creators are filled with confidence. They believe they are ready to take the web to another level, and all of that in a landscape that, at least for me, doesn't necessarily cry for another React framework. Let's find out whether Remix is about to prove the way we wrote our front-end apps has been far from perfect.


I will say it right from the start: I am very happy with Next.js. This website was built with it, as were many of my commercial applications. Over the last year, in my eyes, it went from a right tool for a particular type of job, to a right tool to any job.

I am satisfied with the defaults it comes with (the prime example is bundling), the ecosystem is vivid, and it tilts the window to the server-side realm just enough to not get overwhelmed with it. With confidence I will say that IMO, over the past year, Next.js has grown to be the driver of progress in the front-end landscape (perhaps even going beyond React).


As far as I understand it, Remix was built with different goals in mind. Although it doesn't ignore the strengths of Next.js (bear in mind that the main reason developers paid attention to Vercel's framework was the "getServerSideProps" method), it offers a fresh look on what we have neglected over the last couple of years of front-end development.

If I would have to guess what Remix team is trying to express with its framework, it would be the following:

"Look, we know web development is going full steam ahead. Everything nowadays is a web app. Devices can handle more and more, which pushes us to ignore what just a while ago we used to preach. Those web principles are not relics of the past, though. The absence of them in the modern day web holds back the progress. If we try to run so fast on such weak legs, we are bound to eventually wobble."

I read these sentences from in between the lines of Remix documentation that focuses on some concepts you probably haven't seen a framework to highlight for a long time:

  • Optimistic UI
  • Progressive enhancement
  • Semantic and fully utilized HTML
  • Accessibility

Those ideas affect how Remix handles the following:

  • Server-side code
  • Forms
  • Routing

and I can promise you we will cover all of those in this blog post.



1. Introduction

Today, we will build a little tongue-in-cheek website: a remix app running on Remix. Excuse me, what?

The app will allow the user to submit a remix of a song. It will include three routes:

  • the main page with a "submit a remix" modal ➜ "/"
  • the individual song page ➜ "/song/[id]"
  • the individual remix page ➜ "/song/[id]/remix[id]"

We need to store our songs and remixes somewhere and I decided to use Supabase for it. However, in this tutorial, I will focus on the features of Remix, while abstracting the database layer away.

For smooth further reading, I advise an intermediate knowledge of React and TypeScript.

2. Initializing Remix application

Diving into Remix documentation, you immediately realize the creators wanted to launch a fully-fledged product. You don't get the sense of "work underway" kind of a framework, where nothing feels stable and is undocumented. The first steps with Remix really prove it:

npx create-remix@latest

Remix welcomes you with a nice CLI that right away forces you to choose a deployment target. In my case, I chose Vercel for two reasons:

  1. The unmatched developer experience
  2. I wanted to verify how it's going to work on, no way around it, a platform created by its direct competitor (Vercel is the company behind Next.js)

You can see the result of that choice in step five.

The only other major choice Remix put in front of me was whether I want to use TypeScript or not. The answer was obviously affirmative.

After the questionnaire, Remix was ready to do its work and welcome me with a bunch of generated files. The first impression is: wow, that's quite a lot of files. The boilerplate takes the approach of showing all of the major features of the framework and it can be a little overwhelming at first.

What is nice is that Remix generates different README.md based on the choices you've made during the initialization of the app. This way, I found out that regardless of what the documentation says, besides npm run dev, I have to also launch npm start in another process, to spin up the Vercel server.

Even before that, we are warned that we should link the app with a new project on Vercel. We do it via vercel link command - just remember, you should not link it with an existing project.

Finally, I was able to jump into the code. I decided to get rid of some of the files in order to reduce the cognitive overload. What I ended up with is:

Files structure

Let's go over the responsibility of each of Remix specific files:

  • root.tsx 👈 this is a wrapper for our entire application
  • routes/index.tsx 👈 our first route, the "/"
  • entry.client.tsx & entry.server.tsx 👈 generated by the CLI, we will not bother with it

These are not the only files Remix generates for us. We also have:

  • the entire server directory with build and index.js
  • remix.config.js and remix.env.d.ts
  • vercel.json

All of those are related either to the Remix server or the build process. They are not our concern for now, so let's focus on building the UI.

And that starts with root.tsx, as it is a starting point and a wrapper of our entire application. This file includes a general layout of our application, global CSS imports, ErrorBoundary, CatchBoundary, something called RouteChangeAnnouncement... So, quite a lot. IMO, some of that should be abstracted away or at least moved to another file, as in the current form it is easy to bounce from it.

If you manage to parse the entirety of the root.tsx, you will probably think of some future use cases for it: initializing a library, basing styling and so on. In my app, all I need is simplifying the Layout function to look like this:

// filename: app/root.tsx
// ...
function Layout({ children }: React.PropsWithChildren<{}>) {
  const location = useLocation();

  return (
    <div className="layout">
      <header>
        <nav aria-label="Main navigation">
          {location.pathname !== "/" && <Link to="/">🏠</Link>}
        </nav>
      </header>
      <div>
        <main>{children}</main>
      </div>
    </div>
  );
}
// ...

3. Setting up routes and navigation

Let's start with our index route, which will feature a list of remixed songs:

// filename: app/routes/index.tsx
export default function SongsPage() {
  return (
    <section>
      <h2>List of songs:</h2>
    </section>
  );
}

A Remix route needs nothing more than a default exported JSX component. The name of our component doesn't matter, but the kind of export we use does.

We won't stop here, though. Following the boilerplate code, we will add metadata to our page:

// filename: app/routes/index.tsx
// ...
import type { MetaFunction } from "remix";

export const meta: MetaFunction = () => {
  return {
    title: "🎵 Songs 🎵",
    description: "List of songs!",
  };
};
// ...

Remix uses a function called meta with a named export to smuggle a page metadata. What I didn't manage to find in the documentation is how to use the server-side fetched data to set the metatags, which is quite a common scenario.

The last thing we need is a "loader" function. To explain what it is, we will cite the official documentation:

"Each route can define a "loader" function that will be called on the server before rendering to provide data to the route."

The loader can reach for both the static and async data, and then use it to render the component on the server-side. If all your page does is render data, it doesn't even need client-side JavaScript! And it's not just talk - go and check it, disable JavaScript on a Remix site.

What we need on our index page is a list of songs the users have remixed. We want to use Remix's ability to fetch data before rendering the page, thus we will use the said loader:

// filename: app/routes/index.tsx
// ...

import type { LoaderFunction } from "remix";
import { getSongs } from "~/src/api";

export const loader: LoaderFunction = async () => {
  const { data: songs } = await getSongs();

  return json(songs);
};
// ...

The convention is exactly the same as we noticed in setting the metadata - we utilize a named export of a function called loader. If we analyze the TypeScript definition of it, we see:

export interface LoaderFunction {
  (args: DataFunctionArgs):
    | Promise<Response>
    | Response
    | Promise<AppData>
    | AppData;
}

export interface DataFunctionArgs {
  request: Request;
  context: AppLoadContext;
  params: Params;
}

So the function returns either a promise or straight up data (if we, for example, import it from a static file). We can supply it with params, which we will use later on.

Like I said, I will abstract away the Supabase database layer - all you need to know is that the getSongs function returns a promise with data. In case you don't believe me, this one time I will let you have a small sneak-peak into this function:

// filename: app/src/api.ts
import { PostgrestResponse } from "@supabase/postgrest-js";
import { supabase } from "./utils/supabaseClient";

export type Song = {
  id: string;
  created_at: string;
  name: string;
};

export const getSongs = async () => {
  return await supabase.from("songs").select("*") as <PostgrestResponse<Song[]>;
};

If you are still interested in integration with Supabase, dive into the repository I created for this app.

All that's left in this page is using the fetched data. We will access it using useLoaderData Remix hook:

// filename: app/routes/index.tsx
import { Link, useLoaderData } from "remix";

export default function SongsPage() {
  const songs = useLoaderData<Song[]>();

  return (
    <section>
      <h2>List of songs:</h2>
      <ul>
        {songs.map((song) => (
          <li key={song.id}>
            <Link to={`/song/${song.id}`} prefetch="intent">
              {song.name}
            </Link>
          </li>
        ))}
      </ul>
    </section>
  );
}

Its role is simple - "it returns the data from the current route's loader".

Another addition to SongsPage is the Link component - it enables us to navigate between pages.

That leaves the entire app/routes.index.tsx file looking like this:

// filename: app/routes/index.tsx
import type { LoaderFunction, MetaFunction } from "remix";
import { json, Link, useLoaderData } from "remix";
import { getSongs, Song } from "~/src/api";

export const loader: LoaderFunction = async () => {
  const { data: songs } = await getSongs();

  return json(songs);
};

export const meta: MetaFunction = () => {
  return {
    title: "🎵 Songs 🎵",
    description: "List of songs!",
  };
};

export default function SongsPage() {
  const songs = useLoaderData<Song[]>();

  return (
    <section>
      <h2>List of songs:</h2>
      <ul>
        {songs.map((song) => (
          <li key={song.id}>
            <Link to={`/song/${song.id}`} prefetch="intent">
              {song.name}
            </Link>
          </li>
        ))}
      </ul>
    </section>
  );
}

We have now built our first Remix page 🥳:

First Remix page


We move on to our next route: an individual song. We want our URL to look like this: "https://www.xyz.com/song/[id]".

In this case, Remix routing system offers a rather elegant solution. First, we need to create a folder called "song" and then a file $id.tsx in it. The "$" symbol stands for a URL parameter. The component itself will look very similar to our index page:

// filename: app/routes/song/$id.tsx
import type { LoaderFunction, MetaFunction } from "remix";
import { json, Link, useLoaderData, useParams } from "remix";
import { getRemixes, Remix } from "~/src/api";

export const loader: LoaderFunction = async ({ params: { id } }) => {
  if (!id) {
    throw new Response("Not Found", { status: 404 });
  }

  const { data: songs } = await getRemixes({ songId: id });

  return json(songs);
};

export const meta: MetaFunction = () => {
  return {
    title: "🎧 Song 🎧",
    description: "Song page",
  };
};

export default function SongPage() {
  const remixes = useLoaderData<Remix[]>();
  const { id } = useParams();
  return (
    <section>
      <h2>Song: {id} remixes:</h2>
      <ul>
        {remixes.map((remix) => (
          <li key={remix.id}>
            <Link
              to={`/song/${remix.song}/remix/${remix.id}`}
              prefetch="intent"
            >
              {remix.name}
            </Link>
          </li>
        ))}
      </ul>
    </section>
  );
}

There is one noticeable difference, though. We try to cover the scenario when the song id was not provided:

// filename: app/routes/song/$id.tsx
// ...

export const loader: LoaderFunction = async ({ params: { id } }) => {
  if (!id) {
    throw new Response("Not Found", { status: 404 });
  }
  // ...
};

A song page

Things get a bit more interesting with our next route: "/song/[id]/remix[id]". As far as I see, there is no example in the documentation that covers the case of two params being included in the URL. I managed to figure it out on my own, but I am not quite happy with the result.

The name of the file that matches our desired route is: app/routes/song/$id.remix.$remixId.tsx. That means we have a file with a name: "$id.remix.$remixId.tsx" and that doesn't look too good, in my opinion.

Let's break down this slightly convoluted name:

  • "$id" => songId
  • ".remix." => changes to "/remix/"
  • "$remixId" => remixId

So, as you can see, the usage of dots to join the path is what creates the confusion. I suspect it's something you get used to pretty quickly, and I trust the team behind the most popular React router to have good reasons to do it this way.

The page itself looks like this:

// filename: app/routes/song/$id.remix.$remixId.tsx
import { json, LoaderFunction, MetaFunction, useLoaderData } from "remix";
import { getRemix, Remix } from "~/src/api";

export const loader: LoaderFunction = async ({ params: { remixId } }) => {
  if (!remixId) {
    throw new Response("Not Found", { status: 404 });
  }

  const { data: songs } = await getRemix({ id: remixId });

  const song = songs?.[0];
  return json(song);
};

export const meta: MetaFunction = () => {
  return {
    title: "🎹 Remix 🎹",
    description: "Remix page",
  };
};

export default function RemixPage() {
  const remix = useLoaderData<Remix>();

  return (
    <section>
      <h2>Remix: {remix.id}</h2>
      <span>{remix.name}</span>
    </section>
  );
}

A remix page


Anyway, that wraps up the process of creating our routes. Let's proceed with our next category: forms.

4. Writing forms

Remix's approach to forms is oldschool. They try to provide modern User Experience, combined with the traditional form HTML API. When was the last time you saw an action tag on a form? Well, you see plenty of it in Remix's documentation.

The process starts with writing a standard form:

// filename: app/routes/index.tsx
// ...
const AddRemixForm = () => {
  const songs = useLoaderData<Song[]>(); // 👈 we use server-side fetched data
  // to fill our select with options

  return (
    <form method="post">
      <fieldset>
        <label>
          Name
          <input type="text" name="name" placeholder="Remix name" />
        </label>
        <label>
          Song
          <select name="song">
            {songs.map((song) => (
              <option value={song.id} key={song.id}>
                {song.name}
              </option>
            ))}
          </select>
        </label>
      </fieldset>
      <button type="submit">Submit</button>
    </form>
  );
};
// ...

Then, we need to provide the server-side logic to process the data after submission. As with meta and loader, we need to declare and export a function called action:

// filename: app/routes/index.tsx
// ...
import { ActionFunction, redirect, json } from "remix";
import { addRemix } from "~/src/api";

export const action: ActionFunction = async ({ request }) => {
  const formData = await request.formData();
  const name = formData.get("name");
  const song = formData.get("song");

  if (!name || !song || typeof name !== "string" || typeof song !== "string") {
    return json(["Name or song was not provided"], { status: 400 });
  }

  const { data } = await addRemix({ name, song });
  const remix = data?.[0];

  if (remix) {
    return redirect(`/song/${song}/remix/${remix.id}`);
  } else {
    throw new Response("Error!", { status: 500 });
  }
};
// ...

As you can see, Remix continues to walk us through a history lesson. Luckily, the creators don't want the users to feel like they are stuck in a stone age, so they deliver a way to enhance the UX:

// filename: app/routes/index.tsx
// ...
import { Form, useTransition, useActionData } from "remix";

const AddRemixForm = () => {
  const songs = useLoaderData<Song[]>();
  const transition = useTransition();
  const actionData = useActionData();

  return (
    <Form method="post">
      {* ... *}
      {actionData && (
        <ul>
          {actionData.map((error: string, index: string) => (
            <li className="error" key={index}>
              {error}
            </li>
          ))}
        </ul>
      )}
      <button type="submit">
        {transition.state === "submitting" ? "Loading..." : "Submit"}
      </button>
    </Form>
  )
}

With useTransition and useActionData hooks, we have all the tools needed to provide a modern experience. The first one returns an object that will single-handedly provide Remix a lot of fans from xState community:

export declare type TransitionStates = {
    Idle: {
        state: "idle";
        type: "idle";
        submission: undefined;
        location: undefined;
    };
    SubmittingAction: {
        state: "submitting";
        type: "actionSubmission";
        submission: ActionSubmission;
        location: Location;
    };
    SubmittingLoader: {
        state: "submitting";
        type: "loaderSubmission";
        submission: LoaderSubmission;
        location: Location;
    };
    LoadingLoaderSubmissionRedirect: {
        state: "loading";
        type: "loaderSubmissionRedirect";
        submission: LoaderSubmission;
        location: Location;
    };
    ...

useActionData is the action's equivalent of useLoaderData, it passes to the client everything the action returns, so, for example, all the validation errors. Our today's website UI is unsavable, so we will not bother with more eye candy.

And that's all our first Remix website is going to be. In a simple application, we managed to tick the boxes for the majority of core features of this framework. Now it's time to share this baby with the rest of the world:

5. Deployment

Vercel error

Well, that's not what I was expecting. It seems like my application doesn't boot on Vercel, for the reason stated on the screenshot. That's a bummer.

Unfortunately, I didn't manage to find any information in the docs on what to do if we want to change the deployment target. What's ironic is that in the CLI, when we are asked where do we want to deploy, we are ensured that:

"Choose Remix if you're unsure, it's easy to change deployment targets."

That's a bit unfortunate, for sure, but I suspect the people behind Remix have a solid headache right now with all bug reports coming in. If I don't manage to find any solution to that, I will surely add mine as well.

6. Verdict

In an ecosystem where many frameworks decide to abstract away as much overhead as possible, it's refreshing to see a more traditional approach to building web apps. Remix is making a lot of things easier, but it also puts the developer to work - and work under restrictions one would say we should never drift away from, for the sake of the web.

I can't recall a launch of a framework that was so successful - the creators know exactly what they want to do, they gathered a very competent team and supplied us with multiple integrations on the day one (e.g. StackBlitz).

It does not mean it's perfect, though. I managed to experience some issues while working with it (e.g. the server froze), I didn't succeed to deploy my app to Vercel, and there are some aspects of the framework I straight up have doubts about:

  • the overwhelming amount of boilerplate code (although partially it was simply to display common use cases)
  • the decision to expose many parts of the system to the developer (notice the amount of files the CLI spits out). I don't want to babysit all of that code
  • the syntax for nested routes

Luckily, Remix doesn't need to be perfect right from the start and it certainly wasn't created exclusively to suit my taste. Although the launch day of Remix was a big event for the entire React community, I suspect the next few days are going to be even bigger for the core team itself, because they have a lot of work to do and a lot of conclusions to draw.

Bugs aside, the authors succeeded greatly with at least one thing: pointing out the neglectance of core web values, such as progressive enhancement. Once Remix reminds you about them, you surely won't come back to your everyday job and continue to ignore them, and thus the entire front-end ecosystem benefits from the existence of this framework. For this reason and many others, I thank the entire team, wish them all the best and I will definitely keep an open eye 👀 on the future of Remix.