Sergio Xalambrí

Add returnTo behavior to Remix Auth

If you're using Remix Auth, you may want to add support to return the user to where it was before redirecting them to login.

This is usually done when the user access a private page, so let's say the user goes to /profile/edit but the user is not authenticated, so you want to redirect them to the login page but after the login take them back to the /profile/edit page.

Let's say we have a /login page which will take the user to Auth0 login page.

So you will have something like this:

import { ActionFunction, LoaderFunction } from "remix";
import { auth } from "~/services/auth.server";

// support Form, Link and redirects to /login
export let action: ActionFunction = ({ request }) => login(request);
export let loader: LoaderFunction = ({ request }) => login(request);

async function login(request: Request) {
  return await auth.authenticate("auth0", request, {
    successRedirect: "/home",
    failureRedirect: "/",
  });
}

Something super simple, now let's create a cookie to store the returnTo value.

export let returnToCookie = createCookie("return-to", {
  path: "/",
  httpOnly: true,
  sameSite: "lax",
  maxAge: 60, // 1 minute because it makes no sense to keep it for a long time
  secure: isProduction(),
});

Now, let's update the /login page to set this returnToCookie based on the returnTo search param.

import { ActionFunction, LoaderFunction } from "remix";
import { auth } from "~/services/auth.server";
import { returnToCookie } from "~/services/cookies.server";

export let action: ActionFunction = ({ request }) => login(request);
export let loader: LoaderFunction = ({ request }) => login(request);

async function login(request: Request) {
  let url = new URL(request.url);
  let returnTo = url.searchParams.get("returnTo") as string | null;

  try {
    return await auth.authenticate("auth0", request, {
      successRedirect: returnTo ?? "/home",
      failureRedirect: "/",
    });
  } catch (error) {
    if (!returnTo) throw error;
    if (error instanceof Response && isRedirect(error)) {
      error.headers.append(
        "Set-Cookie",
        await returnToCookie.serialize(returnTo)
      );
      return error;
    }
    throw error;
  }
}

function isRedirect(response: Response) {
  if (response.status < 300 || response.status >= 400) return false;
  return response.headers.has("Location");
}

Way more complicated? What we are doing is:

  1. Get the returnTo from the URL
  2. Do the authenticate call as usual
  3. In successRedirect to take the user to returnTo or /home, this way if it's already authenticated it will go to the returnTo
  4. Catch the thrown error, this can be a Response with a redirect
  5. If returnTo is not defined throw the error/response immediately
  6. If it's a Response and is a redirect, append a Set-Cookie header with the returnToCookie serialized and throw the response
  7. If it's not a Response or is not a redirect, throw the error/response

With this, in case the user is not authenticated we will store the returnTo in the cookie so we can use it later after they come back from Auth0.

Finally, in the callback URL from Auth0 we will add this:

import { LoaderFunction, redirect } from "remix";
import { auth } from "~/services/auth.server";
import { returnToCookie } from "~/services/cookies.server";
import { commitSession, getSession } from "~/services/session.server";

export let loader: LoaderFunction = async ({ request }) => {
  // get the returnTo from the cookie
  let returnTo =
    (await returnToCookie.parse(request.headers.get("Cookie"))) ?? "/home";

  // call authenticate to complete the login and set returnTo as the successRedirect
  return await auth.authenticate("auth0", request, {
    successRedirect: returnTo,
    failureRedirect: "/signup",
  });
};

With this, we can get the returnTo value and redirect the user there after the login.

Now let's change how we check if the user is authenticated, let's say we have our /profile/edit route, we'll typically have this in the loader:

import type { LoaderFunction } from "remix";
import { auth } from "~/services/auth.server";

export let loader: LoaderFunction = async ({ request }) => {
  let token = await auth.isAuthenticated(request, {
    failureRedirect: "/login",
  });

  // more code
};

We can now change it to this:

import type { LoaderFunction } from "remix";
import { auth } from "~/services/auth.server";

export let loader: LoaderFunction = async ({ request }) => {
  let token = await auth.isAuthenticated(request, {
    failureRedirect: `/login?returnTo=/profile/edit`,
  });

  // more code
};

With this change, we'll now redirect the user to the login page if they are not authenticated but we will get the current URL (with both pathname and search params) and use it for the returnTo value.


If instead of Auth0, you have your own login page with a form or buttons, you can make the loader do something like this:

import { LoaderFunction, json } from "remix";
import { auth } from "~/services/auth.server";
import { returnToCookie } from "~/services/cookies.server";

export let loader: LoaderFunction = async ({ request }) => {
  let url = new URL(request.url);
  let returnTo = url.searchParams.get("returnTo");

  let headers = new Headers();
  if (returnTo) {
    headers.append("Set-Cookie", await returnToCookie.serialize(returnTo));
  }

  let data = await getData(request);

  return json(data, { headers });
};

With this, when the user goes to your login page you will set the cookie using the returnTo value from the URL, then when the user submits the login form to trigger the login with some OAuth2 provider or any other strategy you will already have the cookie and the callback will be able to read it.