How toAdd rolling sessions to Remix

Rolling sessions is a technique to extend the maxAge of a session cookie by resetting the cookie's expiration date with each response.

This technique keeps the users logged in for an extended period as the user still navigates around the app.

In Remix, the recommended way to commit a session and send the Set-Cookie header to the user is by doing it on an action function, which is essential to help avoid race conditions.

If you commit the session on two loaders on a document request, loaders run in parallel, meaning the last one to complete will overwrite the session, and you can't know which one will end last. Doing it on actions ensures only one action can run simultaneously.

But let's say we have a sensitive app, like a bank app, so we want to make sure the user authentication remains for a short period, like 5 minutes, but if the user is still active, we want to extend the session for another 5 minutes. We can do that by using a rolling session.

The best place to do this is not on the loader but in the entry.server file. This file in a newly created app looks something like this:

import { PassThrough } from "stream";
import type { EntryContext } from "@remix-run/node";
import { Response } from "@remix-run/node";
import { RemixServer } from "@remix-run/react";
import isbot from "isbot";
import { renderToPipeableStream } from "react-dom/server";

const ABORT_DELAY = 5000;

export default function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext
) {
  return isbot(request.headers.get("user-agent"))
    ? handleBotRequest(
        request,
        responseStatusCode,
        responseHeaders,
        remixContext
      )
    : handleBrowserRequest(
        request,
        responseStatusCode,
        responseHeaders,
        remixContext
      );
}

function handleBotRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext
) { ... }

function handleBrowserRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext
) { ... }

So the first thing we need is to have a SessionStorage object. The most common way to create one is to create a session.server.ts with something like this.

import { createCookieSessionStorage } from "@remix-run/node";

export let sessionStorage = createCookieSessionStorage({
  cookie: {
    name: "session",
    maxAge: 60 * 5, // 5 minutes
    // more cookie options here
  },
});

First, we need to split this to do two things.

  1. Create and export a Cookie object
  2. Create the SessionStorage using the Cookie object
import { createCookie, createCookieSessionStorage } from "@remix-run/node";

export let sessionCookie = createCookie("session", {
  maxAge: 60 * 5, // 5 minutes
  // more cookie options here
});

export let sessionStorage = createCookieSessionStorage({
  cookie: sessionCookie,
});

Now that we have both the session cookie and the session storage, we can go to our entry.server, and in the handleRequest function, let's add our rolling session support.

export default function handleRequest(
  request: Request,
  responseStatusCode: number,
  responseHeaders: Headers,
  remixContext: EntryContext
) {
  let cookieValue = await sessionCookie.parse(
    responseHeaders.get("set-cookie")
  );
  if (!cookieValue) {
    cookieValue = await sessionCookie.parse(request.headers.get("cookie"));
    responseHeaders.append(
      "Set-Cookie",
      await sessionCookie.serialize(cookieValue)
    );
  }

  return isbot(request.headers.get("user-agent"))
    ? handleBotRequest(
        request,
        responseStatusCode,
        responseHeaders,
        remixContext
      )
    : handleBrowserRequest(
        request,
        responseStatusCode,
        responseHeaders,
        remixContext
      );
}

What our code does is

  1. Check if some loader is trying to set the session cookie. If it is, we don't need to do anything
  2. If there is no session cookie set, we get the session from the request and commit it
  3. We send the commitSession result in the Set-Cookie header of the response

Now, if we reload the page, the browser will make a document request to the server, our handleRequest function will run again, and we will commit the session again, resetting the cookie's expiration date. The user will stay logged in for another 5 minutes.

So far, this only works for document requests. If we also want to add this to client-side navigations, we can add a handleDataRequest export to our entry.server file.

import type { HandleDataRequestFunction } from "@remix-run/node";

export let handleDataRequest: HandleDataRequestFunction = async (
  response: Response,
  { request }
) => {
  return response;
};

This function is optional in entry.server, and Remix execute it after any data request, which are requests with the _data search param, so the request Remix made by itself when the user does client-side navigation, but not for resource routes.

So to add rolling sessions here, we can do the same thing we did in the handleRequest function, but we need to ensure we don't commit the session if the request is already trying to set it.

export let handleDataRequest: HandleDataRequestFunction = async (
  response: Response,
  { request }
) => {
  let cookieValue = await sessionCookie.parse(
    responseHeaders.get("set-cookie")
  );
  if (!cookieValue) {
    cookieValue = await sessionCookie.parse(request.headers.get("cookie"));
    responseHeaders.append(
      "Set-Cookie",
      await sessionCookie.serialize(cookieValue)
    );
  }

  return response;
};

And by doing this, we will have rolling sessions for document requests and client-side navigations.

Finally, this is now available on Remix Utils rollingCookie function.