Skip to content
September 11, 2023

Working with Remix

The conventions I use when working with Remix

These past months I've been working with Remix. The release of Next.js 13 and its follow-ups didn't make much impression on me, and if anything they were kind of a deal breaker. Consequently, I decided to take a closer look at Remix, and I was blown away by its ease of use.

In short, I find the Remix approach simpler. I write my backend, and I use the loader & action functions to communicate with React. I'm oversimplifying, but that's all I perceive. In reality, it's a well thought, and complex framework that does such a good job hiding all the complexity from me. And it just works, everywhere.

Anyway, let's go through some conventions I decided on.

Table of contents

Throw in loaders, return in actions

By reading the documentation you might have noticed some snippets like this one:

export async function loader({request, params}: LoaderArgs) {
  // ...
  if (invoice === null) {
    throw json('Not Found', {status: 404}); // new Response with `application/json` header

Take a look at the docs for redirect & json, if you're not familiar

Now a question to be asked is what's the difference between throwing and returning a response?

When it comes to redirects (30X status codes), there's no visible difference. It's down to semantics. Let's see two examples:

  • Password reset success - Let's take the user to the login page return redirect('/login')
  • No session found - Nope. No access for you throw redirect('/login')

By throwing, you let the next developer understand that the redirect is forced due to some bad request.

When it comes to every other response (non-30X status) though, it's all about how you handle the errors. When throwing a response, we diverge from the happy path, and the error is handled in the closest ErrorBoundary. This isn't very helpful at times, as you don't want to show an ErrorBoundary if a username is taken.

I usually like to do the following:

  1. Throw in loaders. The page can't be rendered properly. Many things can happen here. (Missing permissions, database errors, etc.) The page is broken, so rendering the ErrorBoundary makes sense.
  2. Return in actions. Handle the errors as values, and depending on the severity render the appropriate UI. Validation error? Inline the error message. Database hiccup? Could be a toast.

Of course, this isn't set in stone, but a good rule of thumb for me.

Parse formData

What fresh air to just use uncontrolled components, and gather everything in a FormData instance, right? Unfortunately, everything you pick from it is typed as FormDataEntryValue | null.

There are all sorts of validation libraries you can use, but I like to keep it simple. I gather the formData, parse them on the server with zod, and return validation errors instead.

Something like this:

export async function action({request}: {request: Request}) {
  const formData = await request.formData();
  const validation = validate(Object.fromEntries(formData));
  if (!validation.success) {
    return json({ok: false}, {status: 422});
  // continue

Unrelated to Remix, I'm experimenting with effect-ts, so here's what I really do:

I'm not an Effect-ts expert, happy to be corrected and listen to suggestions. Here's my Twitter/X
export const action = withAction(
  Effect.gen(function* (_) {
    const {request} = yield* _(ActionArgs);
    const formData = yield* _(Effect.promise(() => request.formData()));
    const {validate, execute} = resetPassword();
    const props = yield* _(validate(Object.fromEntries(formData)));
    yield* _(execute(props));
    return new Redirect({
      to: '/login?resetPassword=true',
      init: request,
      InternalServerError: () => ServerError({})),
      UserNotFoundError: () => BadRequest({errors: ['User not found']})),
      PasswordResetTokenNotFoundError: () => BadRequest({errors: ['Token not found']})),
      ValidationError: (error) => BadRequest({errors: error.messages})),

If this scares you ignore it. What I want to highlight is how flexible Remix is. If I want to overengineer something, I can do so.

Improved response types

I find the infered types that useLoaderData, useActionData & useFetcher return to be slightly incorrect (Issue-3931). For that case I use remix-typedjson and its hooks. So instead of useFetcher I use useTypedFetcher and so on.

// (taken from remix-typedjson docs)
const fetcher = useTypedFetcher<typeof action>();; // data property is fully typed

I'm on the fence about this, as I'm using a "patched" version of Remix's helpers. There might be dragons here, so feel free to ignore this advice.

Parse ENV variables on load

This isn't something groundbreaking, or strictly related to Remix, but I never notice it when reading similar introductory articles.

Essentially, don't do this:

const SESSION_SECRET = process.env.SESSION_SECRET as string;

Instead, parse process.ENV, and let the application crash if something is missing. You don't want to have your app running, only to find out that your emails were never sent.

Here's what to do instead:

const envValidationSchema = zod.object({
  SESSION_SECRET: zod.string().nonempty(),
// Throw on-load if missing
const config = envValidationSchema;
// from here on config.SESSION_SECRET is populated

The same goes for all other similar instances. Initializing database/SMTP connections, etc

const envValidationSchema = zod.object({
  SMTP_HOST: zod.string().nonempty(),
  SMTP_SECURE: zod.coerce.boolean(),
  SMTP_USER: zod.string().min(2),
  SMTP_PASSWORD: zod.string().min(8),
  SMTP_PORT: zod.coerce.number(),
  EMAIL_FROM: zod.string().email(),
// Throw on-load if missing
const config = envValidationSchema.parse(process.env);
const transporter = createTransport({
  host: config.SMTP_HOST,
  port: config.SMTP_PORT,
  secure: config.SMTP_SECURE,
  auth: {
    user: config.SMTP_USER,
    pass: config.SMTP_PASSWORD,
  pool: true,

You can colocate this in a single env.server.ts file, but whatever you do, parse your env variables.

Feature organization & business logic

Here's how I organize my projects (so far)

    # example
    - membership-invitation.ts
    - membership.ts
    # ..
    - org.ts
    - user.ts
    # example (a bit mouthful, might need to rethink this)
    - invitation-authorization-service.server.ts
    # example
      - create-user.server.ts
      - validation.server.ts
  # all public routes
    # example
      - _route.tsx
      - action.server.ts
      - register-form.tsx
      - register-page.tsx
  # all protected routes
  - &.tsx

I don't like these oversimplified examples where you do everything in your actions. Take that business logic, and create a dedicated file for it. I like to group them under modules/use-cases. There are many approaches, you can go wild with DDD, whatever - just don't blindly add everything to the loaders.

As for the front end, I try to keep my component library in a different (PNPM) workspace. One-offs can live inside the route, while exceptions (e.g. <TeamSwitcher />, <UserNav />) can go in a /components folder.

Oh, almost forgot. I like to colocate everything I need inside the same route. A single folder _route.tsx re-exports everything (meta, default-page, loader, action). I'm writing this piece before Remix V2 lands, so I'm using the Remix Flat Routes package to make it happen.

I'm not dogmatic. I'll happily throw everything out of the window if it's slowing me down. For now, this setup works, but will change in the future.


I want a couple of things from my SSR framework:

  1. Let me run my business logic on the server. Don't be too smart about it.
  2. Make the transition to React seamless.

So far, Remix covers all. There are sharp edges, but nothing blocking.

Is it a batteries-included framework like Laravel, Rails, or (the closest thing in the Node.js land) Adonis.js?

Nope, and I don't mind. If anything the JS ecosystem has so many tools and options, that I would dislike having certain libraries shoehorned.

Remix gets out of my way and lets me just write code. It has the right amount of conventions, without being restricting or weird.