Remix PWA: Practical A-Y Guide

Remix PWA: Practical A-Y Guide

A complete, practical guide for building a PWA with Remix

The web has come a long way since its inception, and the way we build applications has seen a lot of evolution and back-and-forth. Enter Remix, a modern web framework that takes a fresh (yet not-so-fresh) approach to web development, blurring the lines between traditional server-rendered applications and sleek, single-page applications. With its focus on web fundamentals and developer experience, Remix is a breath of fresh air in modern web development.

But what if we could take our web applications a step further, transcending the boundaries of the browser and providing a truly native-like experience? That's where Progressive Web Apps (PWAs) come into play. These innovative applications combine the best of the web and native worlds, offering features like offline functionality, push notifications, and seamless installation on various devices. And when you combine the power of Remix with the magic of PWAs, you unlock a whole new realm of possibilities for building modern, engaging, and performant web experiences.

In this guide, we'll embark on a journey through the world of PWAs, exploring how to harness the capabilities of Remix to create truly progressive web applications whilst building one ourselves. Buckle up, because we're about to take building for the web to new heights, all while having a bit of fun along the way. Let's dive in!

πŸ—οΈ Laying Foundation

This guide is a fully practical one, where we would be going from 0-1 and exploring the details & pitfalls of building a PWA along the way.

Introducing Todoist, the little app we will be building today. Its premise is basically a to-do app that allows you to create multiple to-do lists, update them individually and delete them. Without further ado, let's start building! You could also skip ahead to the PWA section if you are interested in just PWA integration

Word of caution: This post assumes you have a pretty good understanding of Remix. It isn't an introduction-level guide to building Remix apps.

πŸ•οΈ Setting Up

The first step (as always) is to create the remix application. Run the following command to kickstart one:

npx create-remix@latest

Right after that, we would be installing all the main dependencies we would be using right away. Our "stack" would be as follows:

  • SQLite for our database. Picked SQLite because SQL and it is the bestπŸ€·β€β™€οΈ

  • Prisma is our ORM. This is also one of the most popular ORMs out there, so its familiarity would be good.

  • Zod for type enforcing and schema validation. Using zod, we ensure that the data being passed around are as we expect with no exception.

  • Bcryptjs for authentication. Actually, authentication would be quite manual (with cookies and sessions), but we would be using bcryptjs for password hashing.

Let's go ahead and install those:

npm install zod @prisma/client bcryptjs
# install dev dependencies
npm i --save-dev @types/bcryptjs prisma

Regarding styling, you can go ahead and install tailwindcss if you wish, I would be sticking with raw CSS styles (against my wishes) for this guide.

That's it for the basics. Let's go ahead and create our main routes:

  • /: This is our landing page - for now, we will be leaving this blank

  • /login: Login page - for logging in

  • /register: Create a new account - for user account creation

  • /todos: Our "dashboard" - this is where we display all todos.

  • /todos/$todoId: This is where we can view, update and delete a to-do list. We are being efficient with our routes here 😁.

  • /todos/new: This last route for creating a brand new to-do list.

Note when creating the files, we are going to be wrapping the auth routes in a pathless route (_auth) and the authenticated routes with another (_todos)

Let's go ahead and a generic layout for all of our created routes:

export default function Component() {
  return <div>My route</div>;
}

🧱 Building Blocks

In this section we will be building the main blocks of our application, starting out with the databases:

πŸ—ƒοΈ Database

We won't be spending time on the foundational concepts (maybe another post?), but here are some resources if you want to read up more on what we are doing:

Let's go ahead and initialise prisma in our project:

npx prisma init

This does two main things:

  • Creates a prisma file (for everything Prisma) with a schema.prisma file contained within it - for defining our database configuration as well as its schema.

  • Creates a .env file and appends a DATABASE_URL variable to it, this would be a placeholder. You can use a hosted solution; I would be sticking with the simple file. Replacing mine with:

DATABASE_URL="file:./file.db"

Within our schema.prisma, I would be doing two things:

  • Changing our provider from Postgres to SQLite.

  • And updating our schema

model User {
  id       Int        @id @default(autoincrement())
  name     String     @unique
  password String
  lists    TodoList[]
}

model TodoList {
  id     String @id @default(uuid())
  name   String
  todos  Todo[]
  user   User   @relation(fields: [userId], references: [id])
  userId Int
}

model Todo {
  id     Int      @id @default(autoincrement())
  title  String
  done   Boolean
  list   TodoList @relation(fields: [listId], references: [id])
  listId String
}

We would then be "migrating" our schema changes to our database via the command:

npx prisma migrate dev --name init

Finally, to actually initialise Prisma within our application itself. Let's go ahead and create app/.server/db.ts, this is where we would be storing our database client reference, allowing us to interact with our database from with our loaders and actions.

import { PrismaClient } from '@prisma/client'

let prisma: PrismaClient

if (process.env.NODE_ENV === 'production') {
  prisma = new PrismaClient()
} else {
  prisma = new PrismaClient()
  prisma.$connect()
}

export { prisma }

We would be leaving this off at this point, for now, let's move on to authentication. Users must be provided with a way to actually get in, right?

πŸ” Authentication

Authentication! Time for gatekeeping πŸ™Œ. We would be doing it simple in this section too: the pathless route (app/routes/_app.tsx) would ensure you aren't authenticated. The login route would be handling logging in and register route for user creation.

Note, that this guide styling contains the bare minimum whilst we build. We can focus on good looks after we are done.

Our first job is to remove the placeholder in routes/_auth.tsx and replace it with an <Outlet />. We can now view the register and login content.

Building out the login route, we can use the following layout:

export default function Component() {
  return (
    <div className="auth-page">
      <div className="auth-card">
        <h3>Login</h3>
        <p className="error-card">
          {/* this is placeholder text! */}
          Error Occurred!
        </p>
        <Form className="auth-form" method="POST">
          <input type="text" name="username" placeholder="Username" />
          <input type="password" name="password" placeholder="Password" />
          <button>Login</button>
        </Form>
        <p>Don&apos;t have an account? <Link to='/register'>Create account</Link></p>
      </div>
    </div>
  );
}

And the following for the styling:

.auth-page {
  padding: 0 24px;
  display: flex;
  height: 100vh;
}

.auth-card {
  margin-left: auto;
  margin-right: auto;
  max-width: 384px;
  align-self: center;
  display: flex;
  flex-direction: column;
  row-gap: 8px;
  height: max-content;
  border: black solid 1px;
  border-radius: 8px;
  padding: 12px 16px;
}

.auth-card .error-card {
  padding: 4px 8px;
  border: #ba0000 solid 1px;
  border-radius: 4px;
  background-color: #ffc0c0;
  color: #ba0000;
  display: none;
}

.auth-card .error-show {
  display: block;
}

.auth-form {
  display: flex;
  flex-direction: column;
  row-gap: 4px;
}

.auth-form input {
  padding: 2px 4px;
  outline: none;
}

Looking at what we have, the main attraction here is the error box. Despite keeping things simple, we still want to enhance our user experience by keeping them in the app loop and that includes alerting them when errors occur. Regarding the layout, to keep this guide to a minimum, I would be sticking with this simple form. Feel free to expand on it.

I prefer creating a semblance of a layout before diving into business logic, so let's quickly do the /register route too before we start implementing authentication.

export default function Component() {
  return (
    <div className="auth-page">
      <div className="auth-card">
        <h3>Register</h3>
        <p className="error-card">
          {/* Placeholder text */}
          Error occured!
        </p>
        <Form className="auth-form" method="POST">
          <input type="text" name="username" placeholder="Username" />
          <input type="password" name="password" placeholder="Password" />
          <button>Create Account</button>
        </Form>
        <p>Already have an account? <Link to='/login'>Login</Link></p>
      </div>
    </div>
  );
}

That's the basics done! We can move ahead to the implementation now. Create an auth.ts file in app/.server. This is where our session handling and authentication APIs would live. Let's first get the main structure down:

const sessionSecret = process.env.SESSION_SECRET;
if (!sessionSecret) {
  throw new Error("SESSION_SECRET must be set");
}

const storage = createCookieSessionStorage({
  cookie: {
    name: "__todoist_session",
    secure: true,
    secrets: [sessionSecret],
    sameSite: "lax",
    path: "/",
    maxAge: 60 * 60 * 24 * 30, // 30 days
    httpOnly: true,
  },
});

export async function createUserSession(
  userId: number,
  redirectTo: string
) {
  const session = await storage.getSession();
  session.set("userId", userId);

  return redirect(redirectTo, {
    headers: {
      "Set-Cookie": await storage.commitSession(session),
    },
  });
}

function getUserSession(request: Request) {
  return storage.getSession(request.headers.get("Cookie"));
}

export async function getUserId(request: Request) {
  const session = await getUserSession(request);
  const userId = session.get("userId");

  if (!userId || typeof userId !== "number") {
    return null;
  }

  return userId;
}

export async function requireUserId(
  request: Request,
  redirectTo: string = new URL(request.url).pathname
) {
  const session = await getUserSession(request);
  const userId = session.get("userId");

  if (!userId || typeof userId !== "number") {
    const searchParams = new URLSearchParams([["redirectTo", redirectTo]]);

    throw redirect(`/login?${searchParams}`);
  }

  return userId;
}

We have four main APIs, two for getting the user id - one passive and another aggressive. And another two for sessions - one for getting an existing one and the other for creating one.

Aggressive means that it is a give-me-or-get-out. The user session must be available, else they are redirected elsewhere. Passive is the opposite, if there's no user session active, return null.

Let's create our register API, it's a simple premise: create the user and using the created user id, create a new session.

export const AuthFormSchema = z.object({
  username: z.string().min(3, "Username must be at least 3 characters"),
  password: z
    .string()
    .min(6, "Password must be at least 6 characters")
    .max(48, "Password can't contain more than 48 characters"),
});

export async function register({
  password,
  username,
}: z.infer<typeof AuthFormSchema>) {
  const passwordHash = await bcrypt.hash(password, 10);

  const user = await prisma.user.create({
    data: { password: passwordHash, name: username },
  });

  return { id: user.id };
}

Yayyy! We have a semblance of structure, let's go ahead and create the login API. It would have a similar structure to register, just some tiny tweaking:

export async function login({
  password,
  username,
}: z.infer<typeof AuthFormSchema>) {
  const user = await prisma.user.findUnique({
    where: { name: username },
  });

  if (!user) {
    return null;
  }

  const isCorrectPassword = await bcrypt.compare(
    password,
    user.password
  );

  if (!isCorrectPassword) {
    return null;
  }

  return { id: user.id };
}

That is that, whew. If you are paying attention, you will notice a few holes. The most glaring is that we can't log out after logging in. Let's quickly get that done.

export async function logout(request: Request) {
  const session = await getUserSession(request);
  return redirect("/login", {
    headers: {
      "Set-Cookie": await storage.destroySession(session),
    },
  });
}

Implementing authentication, we create an action in /register to handle the creation of a new user.

export const action = async ({ request }: ActionFunctionArgs) => {
  const form = await request.formData()
  const fields = Object.fromEntries(form)

  try {
    const { password, username } = AuthFormSchema.parse(fields)
    const { id } = await register({ password, username })
    return createUserSession(id, '/todos')
  } catch (e) {
    return json({
      error: (e as ZodError).issues[0].message
    })
  }
}

Not the most powerful thing, but this works just well for our case. We don't have client-side validation (don't neglect client-side validation), but the server-side validation does the trick for us in this case. Looking at this, you can guess how the /login implementation would look. If not, we simply replace the register line with login and do a few extra validations in case the user is non-existent

export const action = async ({ request }: ActionFunctionArgs) => {
  const form = await request.formData()
  const fields = Object.fromEntries(form)

  try {
    const { password, username } = AuthFormSchema.parse(fields)
    const user = await login({ password, username })

    if (!user) {
      return json({
        error: 'Invalid username or password'
      })
    }

    return createUserSession(user.id, '/todos')
  } catch (e) {
    return json({
      error: (e as ZodError).issues[0].message
    })
  }
}

On the client, we can implement our error notification box.

export default function Component() {
+  const data = useActionData<typeof action>()

  return (
    <div className="auth-page">
      <div className="auth-card">
        <h3>Register</h3>
+        {data && <p className={`error-card ${data.error ? 'error-show' : ''}`}>
+          {data.error}
+        </p>}
-         <p className="error-card">
-           Error occured!
-         </p>
         // rest of our component
      </div>
    </div>
  );
}

Sweet! We can now register, log in and get redirected to the todos page. But there's still an issue, we can go back to the authentication pages after being logged in. To remove that "feature", we add some checks to the app/routes/_auth.tsx pathless route. That allows the user if they aren't authenticated and kicks them out otherwise.

export const loader = async ({ request }: LoaderFunctionArgs) => {
  const user = await getUserId(request)

  if (user) {
    throw redirect('/todos')
  }

  return null;
}

Now we are fully set (within the premise of "basics") when it comes to authentication. Next up is to actually build the todos, let's go 🌟!

πŸ–₯️ Application Layout

In this section, we will be building out the main todo routes.

Building on what we achieved in the authentication section, we would ensure you cannot navigate to the _todos pages if the user isn't logged in. In our todos pathless route (app/routes/_todos.tsx), we implement an authentication check and replace our placeholder component with a simple <Outlet />:

export const loader = async ({ request }: LoaderFunctionArgs) => {
  return await requireUserId(request, request.url);
}

export default function Component() {
  return <Outlet />;
}

The next step is to implement the server APIs for the todo routes, that way we focus on our routes and utilise the business logic as required. Let's create a todos.ts file within app/.server, this would house all required APIs for todos.

Due to the size of the file, I went ahead and created a GitHub Gist for the code, you can copy it right away:

Now that we have our main todos API, let's handle the UI. Sticking to the tradition, it would be a simple one (barely any styles for now πŸ˜ƒ). Building out the main todo landing page (app/routes/_todos.todos.tsx), we are just "listing all lists":

export const loader = async ({ request }: LoaderFunctionArgs) => {
  const todoLists = await getAllTodoLists(request)

  if (!todoLists) {
    throw new Error("User not found")
  }

  return json({
    list: todoLists.lists,
    username: todoLists.name,
  })
}

export default function Component() {
  const { list, username } = useLoaderData<typeof loader>()

  return (
    <div className="todo-page">
      <nav className="todo-header">
        <Link to='/todos'>Todoist</Link>
        <Link to='/logout'>Logout</Link>
      </nav>
      <div className="todo-content">
        <header className="content-header">
          <h3>{username} Todos</h3>
          <Link to='/new'>New List</Link>
        </header>
        {list.length ? list.map((list) => (
          <Link to={`/${list.id}`} className="todo-list" key={list.id}>
            <h4>{list.name}</h4>
            <ul>
              {list.todos.slice(0, 3).map((todo) => (
                <li key={todo.id}>
                  {todo.done ? "βœ…" : "❌"} {todo.title}
                </li>
              ))}
            </ul>
          </Link>
        )) : (
          <p>No todo lists found</p>
        )}
      </div>
    </div>
  );
}

Don't mention semantic HTML, I literally used tags that looked good then without looking back πŸ˜ƒ.

You might notice an issue here, we are not providing ErrorBoundary for our routes at all, that issue can easily be solved off-screen (implement your own ErrorBoundary πŸ’₯). The next interesting thing is the Logout "button" that navigates to a /logout route, we don't have one available so let's quickly create one at app/routes/logout.ts

export const loader = async ({ request }: LoaderFunctionArgs) => {
  throw await logout(request)
}

Our styles are also simple; one simple stylesheet for all todo routes:

.todo-page {
  min-height: 100vh;
  height: 100%;
}

.todo-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 8px 24px;
  font-size: 18px;
}

.todo-header a:last-child {
  text-decoration: none;
  color: #fff;
  padding: 6px 12px;
  border-radius: 4px;
  background-color: #121212;
}

.todo-content {
  display: flex;
  flex-direction: column;
  padding: 12px 24px;
  row-gap: 8px;
}

.todo-content input {
  outline: none;
}

.content-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.content-header a {
  text-decoration: none;
  color: #fff;
  padding: 6px 12px;
  border-radius: 4px;
  background-color: #121212;
}

I will be providing the code at major points in this code, so if you prefer something a bit more styled, hold on...

Our next focus is creation of new to-do lists, can't list any if there are none, right? In the app/routes/_todos.new.tsx file, we update the code with the following:

export default function Component() {
  const [todos, setTodos] = useState<z.infer<typeof TodoSchema>[]>([])
  const addTodoRef = useRef<HTMLInputElement>(null!)

  const addTodo = () => {
    if (!addTodoRef.current.value) return
    const text = addTodoRef.current.value

    setTodos(prev => [...prev, { text, done: false } as z.infer<typeof TodoSchema>])
    addTodoRef.current.value = ''
  }

  return (
    <div className="todo-page">
      <nav className="todo-header">
        <Link to='/todos'>Todoist</Link>
        <Link to='/logout'>Logout</Link>
      </nav>
      <div className="todo-content">
        <header className="content-header">
          <h3>New List</h3>
        </header>
        <Form method="post">
          <label>
            List Name
            <input type="text" name="name" required />
          </label>
          <input type="hidden" name="todos" value={JSON.stringify(todos)} />
          {todos
            .sort((a, b) => (
              a.done === b.done ? 0 : a.done ? 1 : -1
            ))
            .map(todo => (
              <div key={todo.text}>
                <input
                  type="checkbox"
                  value={todo.text}
                  checked={todo.done}
                  form={undefined}
                  onChange={(e) => (
                    setTodos(prev => prev.map(t => t.text === todo.text ? { ...t, done: e.target.checked } : t))
                  )} 
                />
                <label>{todo.text}</label>
              </div>
            ))
          }
          <div>
            <input type="text" form={undefined} placeholder="Add Todo" ref={addTodoRef} />
            <button
              type="button"
              onClick={addTodo}
            >
              Add Todo
            </button>
          </div>
          <button type="submit">Create</button>
        </Form>
      </div>
    </div>
  );
}

Not the cleanest way of going about it, but good enough. We basically have a form that submits our to-do list in the form of a name for the list - and a set of todos. We also sort our todos by status, so completed todos are relegated to the bottom whilst pending ones are kept at the top.

The nav header is also the same as the main /todos route, so we can extract that code block to the _todos pathless route and wrap the <Outlet /> there with it (avoids repetition of code).

Notice a few inputs have a form attribute that's set to undefined. That, and the absence of a name attribute prevent them from being submitted alongside the form.

On the server side, our action is pretty simple, all we do is pass the form data to the API we created earlier and redirect to the newly created to-do list route

export const action = async ({ request }: ActionFunctionArgs) => {
  const form = await request.formData()
  const formEntries = Object.fromEntries(form)
  const todos = JSON.parse(formEntries.todos as string) as z.infer<typeof TodoSchema>[]
  const name = formEntries.name as string

  const { id } = await createTodoList(request, name, todos)

  return redirect(`/${id}`);
}

Last but not the least, the dynamic todolist route (app/routes/_todos.$todoId.tsx). If you tested what we just provided, we get redirected to the /$todoId route, but it's just placeholder text. Let's do something about that quickly.

export default function Component() {
  const fetcher = useFetcher()
  const { listName, listTodos } = useLoaderData<typeof loader>()

  const [todos, setTodos] = useState(listTodos)
  const addTodoRef = useRef<HTMLInputElement>(null!)

  useEffect(() => {
    setTodos(listTodos)
  }, [listTodos])

  const addTodo = () => {
    if (!addTodoRef.current.value) return
    const text = addTodoRef.current.value

    setTodos(prev => [...prev, { title: text, done: false, id: Math.random() }])
    addTodoRef.current.value = ''

    fetcher.submit({
      type: 'add',
      todo: JSON.stringify({ text, done: false })
    }, {
      method: 'POST',
    })
  }

  return (
    <Fragment>
      <div className="todo-content">
        <p>{listName}</p>
        {todos
          .sort((a, b) => (
            a.done === b.done ? 0 : a.done ? 1 : -1
          ))
          .map(todo => (
            <div key={todo.title}>
              <input
                type="checkbox"
                value={todo.title}
                checked={todo.done}
                form={undefined}
                onChange={(e) => {
                  setTodos(prev => prev.map(t => t.id === todo.id ? { ...t, done: e.target.checked } : t))
                  fetcher.submit({
                    type: 'update',
                    todo: JSON.stringify({ id: todo.id, title: todo.title, done: e.target.checked })
                  }, {
                    method: 'POST'
                  })
                }}
              />
              <label>{todo.title}</label>
            </div>
          ))
        }
        <div>
          <input type="text" form={undefined} placeholder="Add Todo" ref={addTodoRef} />
          <button
            type="button"
            onClick={addTodo}
          >
            Add Todo
          </button>
        </div>
      </div>
    </Fragment>
  )
}

We built an instantaneous system that utilises two data stores, a local state and the database. When a new todo is added, the local state is updated - with a random id, while a round-trip is made to the server that updates the actual database and updates the state in the background (with the actual id). Same things occurs when a todo is toggled between completed and pending state.

The server-side logic is as follows:

export const action = async ({ request, params }: ActionFunctionArgs) => {
  const form = await request.formData()
  const formEntries = Object.fromEntries(form)

  switch (formEntries.type) {
    case 'add': {
      const todo = JSON.parse(formEntries.todo as string)
      await createTodo(params.todoId!, todo)

      return new Response('Successfully created todo')
    }
    case 'update': {
      const { id, ...todo} = JSON.parse(formEntries.todo as string)
      await updateTodo(id, todo)

      return new Response('Successfully updated todo')
    }
    default:
      return new Response('No action specified')
  }
}

export const loader = async ({ request, params }: LoaderFunctionArgs) => {
  const list = await getTodoList(request, params.todoId!)

  if (!list) {
    return redirect('/todos')
  }

  return json({
    listName: list.name,
    listTodos: list.todos,
  })
}

Deleting and editing to-do text is not available (to avoid making this any longer), it still follows the same convention as creation and status editing though.

That's it for this section! We have a basic to-do app that allows us to create and mark to-dos as done, if you want the code right up till this point, it's available on this branch in the todoist repository. If you prefer a minimally styled application that isn't so plain, you can also get the code here.

πŸ“± Progressive Web App (PWA)

Finally, what we've all been looking forward to :). We have a basic app, now let's supercharge it πŸš€

If you have no idea what a PWA is, check out these resources:

  • MDN: A very good, basic set of explanations/introduction to the various concepts and components of PWAs

  • Web.dev: Another extraordinary guide introducing PWAs

  • Remix PWA: The title of this guide. The main framework we would be using to transform Remix into a PWA

Now that you know something about a PWA, let's go ahead with our own app. I would be using the minimal-styles branch (version of our app) to build this out (a slightly better-looking app). First of all, let's install all we need to and create all required components.

We would be using the following packages from the Remix PWA ecosystem:

  • remix-pwa: The CLI. We won't be installing this one, just executing to help scaffold a few things

  • @remix-pwa/dev: The core of Remix PWA. Contains the core vite plugin as well as extra types for our manifest. This would be a dev dependency

  • @remix-pwa/worker-runtime: The runtime for our Service Worker. Think of it as the engine that powers the Service Worker

  • @remix-pwa/sw: A package that is battery-included for everything we might possibly need when dealing with a PWA. If something is missing, file an issue :)

  • @remix-pwa/sync: A package for background sync - a mechanism to allow retrying to the server even when offline. It is a cute mechanism we would exploring soon.

Run the following to install them all:

npm i @remix-pwa/worker-runtime @remix-pwa/sw @remix-pwa/sync
# dev deps
npm i --save-dev @remix-pwa/dev

Let's take a break and explain some of the main concepts mentioned above:

βš™οΈ Service Workers

A Service Worker is basically a middleware between your client and server. What is a middleware, you ask? Think of it as a middleman. You have two ends of a cylinder - on one end, you have your web application running in the browser (the client), and on the other end, you have the server that hosts your application and serves the necessary resources.

The Service Worker acts as the intermediary that sits in the middle of this cylinder, intercepting and potentially modifying the requests and responses that flow between the client and server. It's like having a trusty sidekick who can handle various tasks and make your web application more powerful and efficient.

Service Workers - Courtesy of Web.dev

One of the primary responsibilities of a Service Worker is to enable offline functionality for your web application. When a user accesses your website while offline or in a poor network condition, the Service Worker can serve cached resources from its local cache, ensuring that your app remains accessible and functional, even in the absence of an internet connection.

But that's not all! Service Workers can also handle push notifications, allowing your web app to receive real-time updates and display notifications to the user, even when the app is not currently running in the browser.

πŸ—ΊοΈ Web Manifest

Another key component of PWAs is the Web App Manifest. This JSON file acts as a central place to define various metadata and configurations for your web application, enabling it to be treated more like a native app by the browser and operating system.

Think of the Web App Manifest as a blueprint or a set of instructions that tell the browser and device how to handle and present your PWA. It contains information such as the app's name, icons, color schemes, display mode (e.g., full-screen, standalone), and even instructions for adding the app to the user's home screen or app launcher.

When you add a Web Manifest to your application, you unlock several functionalities including:

  • Installability

  • Customizable Appearance

  • Improved Discoverability

And more...

Now that we have a much better understanding of the two main components of a PWA, let's start transforming ours into one too!

πŸ“Ά Offline Experience

This section could also be called 'Service Workers', as that forms the backbone of offline behaviour in a PWA. Scaffold one immediately by running

npx remix-pwa@latest sw

This creates a very simple entry.worker.ts file within our app directory. If we run our app at this point, nothing happens. That's because we haven't registered the PWA plugin yet in our vite config. Go ahead and add the remixPWA plugin from @remix-pwa/dev to the vite plugins array. We don't need to change any of the default options, they are well-suited for a wide-array of use-cases.

import { vitePlugin as remix } from "@remix-run/dev";
import { installGlobals } from "@remix-run/node";
import { defineConfig } from "vite";
+import { remixPWA } from '@remix-pwa/dev';
import tsconfigPaths from "vite-tsconfig-paths";

installGlobals();

export default defineConfig({
+  plugins: [remix(), tsconfigPaths(), remixPWA()],
});

If we now run the app, you should see (if you did things right) two new messages about installation and activation in your console. And this would be the perfect time to talk about Service Worker lifecycles:

πŸ” Service Worker Lifecycles

A service worker is a script that runs parallel to your web application and helps to manage, intercept and deal with multiple events. There are a definite set of steps that define how a service worker is registered, installed, activated and even terminated.

The lifecycle starts with the registering of the service worker, this is something that the client does on startup. We didn't have to manually register it ourselves, cause the plugin handled it for us. If we wanted to go ahead and do it manually, simply disable it by setting the registerSW option in the plugin to null. Then we would need to invoke navigator.serviceWorker.register() in the client.

After that, the browser then attempts to download and parse the service worker (it's a script, after all). If that is successful, the install event is triggered. By the way, it is triggered only once ☝️.

The installation occurs silently, in the background, even if the user does not install the PWA, without requiring the user permission. After installation, the Service Worker enters an idle state. Where it does not control any "client" (a client is basically your browser tab/window), including your PWA. At this point, it is awaiting the next event - the activate event.

The activate event is fired when a Service Worker is ready to control its clients, this could occur at initial load, or when a previous service worker no longer control any client (and has been disposed successfully). This doesn't mean, though, that the page that registered the service worker will be controlled at once. By default, the service worker will not take control until the next time you navigate to that page, either due to reloading the page or re-opening the PWA or in Remix case, client-side navigation.

In case you are wondering, a Service Worker doesn't work across origins. It only works for one domain/website, which is the web app that registered it. "Clients" (plural) in this context refers to instances where you have more than one tab open for the same website, as service workers operate across all of them.

After the two major events, a service worker operates as usual till a new service worker is found, then a redundancy cycle occurs. The new service worker awaits activation, whilst the old service worker gracefully loses control of clients and then gets terminated/deleted and replaced by the new one. And the cycle keeps on repeating.

The next question is: do service workers live forever then?

🧬 Service Worker Lifespan

Once registered and installed, a service worker can manage all network requests that comes it way. It runs on its own separate thread, allowing it to work before or even after your web app has opened. While service workers run on their own thread, there is no guarantee that in-memory state will persist between runs of a service worker, so make sure anything you want to reuse on each run is available either in IndexedDB or some other persistent storage.

Whilst Service Workers are long living in nature, they could get terminated by the browser due to constraints, time limits or other factors. Even though they are dormant at this point, they automatically get restarted when a network request is made, push event triggered, periodic background sync event or any other event that is handled by the Service Worker.


Okay, now back to the original guide. We now have a better understanding of what the two event listeners in our service worker at the moment do. Two things to also note in our service worker are the self.skipWaiting() and self.clients.claim(): The first one skips waiting automatically and installs itself as soon as it is loaded, and the second one takes control of clients forcibly. Hence, installation and activation occur at once and our service worker is ready to start handling events.

The very first thing I like to do is utilise a custom logger for all our print statements. If we were to deploy our app right now, we get the installation and activation log in our browser console (which doesn't seem right in production). We could alternatively do an environment check via process.env.NODE_ENV, but @remix-pwa/sw has got us covered with the Logger class. Let's add the following to our service worker:

+const logger = new Logger({
+  prefix: 'todoist',
+})

 self.addEventListener('install', event => {
-  console.log('Service worker installed');
+  logger.log('Service worker installed');

   event.waitUntil(self.skipWaiting());
 });

 self.addEventListener('activate', event => {
-  console.log('Service worker activated');
+  logger.log('Service worker activated');

   event.waitUntil(self.clients.claim());
 });

Because we are making constant changes to our service worker and would like to reload all the time, head to the "Application" tab within your browser dev tools and into the "Service Worker" tab, check the "Update on reload" option at the top of the tab. This forces a new service worker update every time we reload the page.

Alternatively, we could use the "Storage" tab under "Application" tab instead to handle re-registering. As this allows us to manually 'reset' our application as needed. And it prevents the Service Worker from getting reloaded after every little change.

We now have a beautiful logger that works only in development. We can go even further and incorporate custom styles but that's just vanity, am I right?

The main focus of this section was offline capabilities, and we would be doing just that. I won't be getting into types of caching in this guide (maybe another one?), we would instead be diving right in. Remix PWA got us covered (again) in the caching aspect with the EnhancedCache class, a cache wrapper that utilizes various "caching strategies" to exhibit various caching behaviours.

That's a mouthful, what does a "caching strategy" mean? Caching can be explained as making your app less dependent on the server and network conditions. By caching resources like HTML, CSS, JavaScript files, images, and API responses locally on the user's device, your app can provide a better experience even when the network is slow or completely unavailable.

Caching strategies define the logic and policies around how resources should be cached and served from the cache. For example, a cache-first strategy would attempt to serve a cached version of a resource first before hitting the network. On the other hand, a network-first strategy would try to fetch the latest version from the network and only fall back to the cached version if the network request fails.

Now that we have an idea of what strategies are, let's move on. Now the next step would be to immediately start instantiating enhanced cache instances everywhere and caching everything cacheable, but that's not how to go about it. No two apps are the same (mostly). What might fly for some, might be an overkill for others and in other cases, downright useless. Let's plan this out

πŸ“¦ Caching Blueprint

Looking at our application, there are a few things we would like to cache. Caching the documents (HTML) would be nice, that ensures navigations are a smooth process. We would also like to cache assets like stylesheets and scripts - this might be bundled assets or assets from within our application. We would also be caching API responses (data) β€” but with a catch. When a user logs out, we delete all authenticated routes from both the document and data cache. That way, we ensure that a malicious user doesn't have access to any of our user's data.

In a bigger application, you would want to set up a better system for caching data responses - like adding exceptions, caching specific routes only, and providing fallbacks as required. We won't be doing any of that here though.


Now that we know what we want to cache, it's time for the "how do we want to cache"? For todoist, we can split our cache three-ways - a document cache, a data cache and an asset cache. For the asset cache, we would be utilizing a cache-first policy. Assets tend to not change often, so they should be able to live long without needing a replacement. Document pages (for document requests) on the other hand, is a bit more flexible. We could use the network first strategy, which attempts to fetch from the network (server) first then fallback to cache. We could also try the Stale-While-Revalidate strategy instead, which fetches from the cache, but updates the cache in the background with server data so the next time we hit the cache, it is the latest version. I prefer network first. Server data would also be handled via the network first policy too. That way, our data is always fresh and up to date.


const dataCache = new EnhancedCache('data-cache', {
  strategy: 'NetworkFirst',
  strategyOptions: {
    // options for the network first strategy
    maxEntries: 50,
    maxAgeSeconds: 3_600 * 24, // 1 day
    networkTimeoutInSeconds: 5,
  }
})

const documentCache = new EnhancedCache('page-cache', {
  strategy: 'NetworkFirst',
  strategyOptions: {
    // options for the network first strategy
    maxEntries: 25,
    maxAgeSeconds: 3_600 * 24 * 7, // 7 days
  }
})

const assetCache = new EnhancedCache('asset-cache', {
  strategy: 'CacheFirst',
  strategyOptions: {
    // options for cache first
    maxAgeSeconds: 3_600 * 24 * 90, // 90 days
  }
})

We instantiate three enhanced cache instances, one for asset, one for data and the last one for document. For the document cache, we limited how many entries (items) can be stored inside the cache to 25 (this can be increased). This is to prevent our cache getting "overrun" by the /$todoId route. We also set a maximum time limit that a cache entry is valid for (7 days), after which the entry is marked as stale and deleted. We also added the "time-to-live" option to the asset cache as well (90 days). For the data cache, we did something similar with one new addition:

  • networkTimeoutInSeconds: this adds a time limit for the fetch event before falling back to cache (defaults to 10).

Explore the strategies and their options in this Remix PWA doc.

Now to actually utilize the caches and handle network requests.

If you looked at most service worker guides, this is where we pull out the fetch event listener within a service worker. In Remix PWA, this is forbidden. The fetch event is handled under the hood via runtimes, which are like internal service workers.

Our app uses the default @remix-pwa/worker-runtime provided by Remix PWA under the hood. If you feel ambitious, you could also go ahead and create your own runtime.

So how do we implement fetching at all? Via the defaultFetchHandler. The default fetch handler acts as a fallback for all requests, and this is where we handle our fetching logic. But why the need for a fallback in the first place?

In Remix PWA, there are functions known as worker route APIs, which are special exports in your routes that act as service worker fetch handlers for just that route.

export const loader = () => {
  // some magic ✨
}

export const workerLoader = () => {
  // remix-pwa worker route api
  //
  // any fetch made to the loader above would be intercepted
  // here and executed in the service worker thread
}

// our UI component goes here

When a request is made, the runtime checks whether a route has worker route API for that request method (workerLoader for GET requests, and workerAction for non-GET request), if it does, that gets executed instead and life moves on. Else, it attempts to fall back to the default fetch handler, which is what we define in our Service Worker.

Now that we get why we need a fallback, let's actually create that fallback

export const defaultFetchHandler = ({ context }: WorkerDataFunctionArgs) => {
  const { request } = context.event;
  const url = new URL(request.url);

  if (isDocumentRequest(request)) {
    return documentCache.handleRequest(request)
  }

  if (isLoaderRequest(request)) {
    return dataCache.handleRequest(request)
  }

  if (self.__workerManifest.assets.includes(url.pathname)) {
    return assetCache.handleRequest(request)
  }

  return fetch(request);
}

There are multiple things to digest here. First of all, we are using context.event.request, not request of WorkerDataFunctionArgs type. That's because Remix PWA mirrors Remix API as much as possible, and the request passed is a stripped request - no search params, no raw URL, etc. On the other hand, the context object provides the FetchEvent object which contains the actual request made.

As a rule of thumb, it is recommended to use the FetchEvent request instead of the stripped request whenever possible.

The next question is: what even is this context object? The context can simply be defined as a global object that is passed across (and around) your service worker, allowing worker route apis, and exported service worker modules to access global data.

The isDocumentRequest is a utility provided by @remix-pwa/sw to detect if a request is a navigation request or not. It's similar to isLoaderRequest which checks for loader requests.

The next thing of note is the worker manifest, which is a global manifest available in the service worker. It contains two things primarily:

  • routes: A route manifest object

  • assets: An array of assets

If the request wasn't handled by any of the "if"s, we pass the request to the server as-is β€” via fetch.

A rule: Like Remix, you must return a Response promise. Returning null in your default fetch handler is not enough, the browser always expects a response no matter how bad things get.

Don't forget to export your default fetch handler!

We have on more thing to do before our app is ready to go off the grid. The default fetch handler handles requests just fine, and whenever Remix needs to access its server components (loaders and actions), it makes a fetch call to them which gets intercepted by the Service Worker. But how can service workers detect client-side navigations? Simply put, it can't. Unless we make it aware that a navigation is happening, it remains oblivious to that fact.

Service Worker -to- Client communication occurs in multiple ways, one of which is via the postMessage method. A way for clients and workers (service worker is a type of web worker) to communicate with each other seamlessly. We can skip the whole science happening there in this article, as Remix PWA provides the two necessary utilities needed for communication. useSWEffect for client-side navigation event communication and NavigationHandler on the service worker end to receive navigation-related messages.

In our root.tsx file, we add the useSWEffect hook. It doesn't need any special configuration, just declare it and you are good to go

export function Layout({ children }: { children: React.ReactNode }) {
  useSWEffect()

  // rest of the layout
}

Within our service worker, we initialise the NavigationHandler. And handle incoming messages via the message event listener.

const messageHandler = new NavigationHandler({
  cache: documentCache,
  logger,
})

self.addEventListener('message', (event: ExtendableMessageEvent) => {
  event.waitUntil(messageHandler.handleMessage(event))
})

We passed in the cache (which must be an enhanced cache). Now we are ready to go off the grid πŸš€!

πŸͺ« Going off the Grid

If Service worker updates on reload are getting out of hand, you can disable the option in the "Service Worker" tab, and instead use the "storage" tab to manually unregister the worker when you want.

Heading back to our browser, we can reload (to update the service worker), navigate a bit β€” this allows us to populate the cache. Then using the "Network" tab, go offline and try and navigate. Viola! We navigate just fine 😁. You might get a few random errors due to missing assets, let's find out why

First and foremost, assets in Remix PWA are handled in two distinct ways depending on environment. When you run npm run dev (development), assets are simply the files located in the public folder. This means any stylesheet or route files aren't cached. Why? Because Remix doesn't build them in development. In production on the other hand, the assets default to the build/client folder, which contains all assets built by Remix.

To test this out, check out the asset-cache under the "Cache storage" tab in the "Application" tab. In dev, this should contain just the favicon route. If we were to stop the server and run npm run build, then npm run start instead, we see more entries in the asset cache like /assets/_todos.new-[random-hash].js as we navigate through the app.

The asset-cache is named asset-cache-v1, not asset-cache. This is due to the versioning functionality in enhanced cache. More on that in another guide

We would be exploring the cache purge and more in the next section.

πŸ” Background Sync

Imagine you're in an area with poor network coverage, and you want to send a message on WhatsApp. You type out your message, hit send, and... nothing happens. The message doesn't seem to be going through. But instead of giving you an error, you see a tiny clock icon at the bottom (instead of βœ”οΈ). When the connection comes back, it sends immediately without requiring any further action required on your part. It just goes. This seamless experience, where your app can continue to function and perform tasks even in challenging network conditions, is made possible by a powerful feature called Background Sync.

Background Sync is a capability that allows web applications to defer certain actions until the user has a stable internet connection. It's like having a trusty assistant who remembers your pending tasks and completes them as soon as the conditions are favorable.

In the context of Progressive Web Apps (PWAs), Background Sync plays a crucial role in providing a reliable and uninterrupted user experience, even in situations with poor or intermittent network connectivity. It enables your PWA to queue up tasks, such as sending messages, uploading files, or synchronizing data, and execute them seamlessly in the background when the network is available again.

In the context of our app: Todoist, an excellent place for this is our /$todoId route. When the user adds or updates the status of a todo whilst offline, we just queue the request and execute later.

Background Sync is reserved for non-GET requests. If you want to handle GET requests offline, use a cache instead.

🧍 Queueing Tasks

We would be writing our first worker route API in this section, which is basically a function in your route, that intercepts requests to server exports.

Let's create the outline in /$todoId:

export const workerAction = async ({ request }: WorkerActionArgs) => {
  console.log('worker action', request.url)

  return null
}

Let's detour a bit. We created a workerAction in app/routes/_todo.$todoId.tsx that does nothing but logs the request URL. Let's go ahead and refresh our service worker

Yup that's right! Route Worker APIs are still pieces of Service Worker code that need to get bundled into the final script. So, this creates a new service worker version

If we navigate to a todolist, and update a todo, we notice something weird. Nothing happens. Adding or updating a todo does nothing. In our console, we get a log message that logs the text "worker action" and the current url. But what happened to our action? It never got called.

Unlike the default fetch handler which requires a Response, Route Worker APIs behave just like Remix's own Route Server APIs (loaders and actions). You can return null, a plain object, a normal response or even a deferred one, the runtime would take care of ensuring the right response gets to the client.

In case you haven't put the puzzle pieces together, when the checkbox was clicked on the client - toggling the state of a todo, a POST request was made to the server. The service worker in the middle, recieved the request and did a check under the hood where it found a workerAction, so instead of using the defaultFetchHandler to handle it, the workerAction handled it instead. It logged a message to the console, and returned null, which triggered the loader to revalidate. The loader has no idea who sent the response, only that the client received a response hence the revalidation. Our local state gets updated when the loader data gets updated hence it seems like nothing happened.

If we want to fetch from the server, we have to do it ourselves

export const workerAction = async ({ request, context }: WorkerActionArgs) => {
  const { fetchFromServer } = context
  console.log('worker action', request.url)

  const response = await fetchFromServer()

  return response
}

Now we have added the fetch ability. Our app now behaves like it previously did, only that we get a new log whenever the user makes any change. By default, the context object contains the fetch event and a fetchFromServer which is basically a simple fetch under the hood.

One more thing is the console statement. Like our service worker, we want to log beautiful messages that don't appear in production. One way to tackle this is to re-instantiate the logger in a separate file that we can then import. Or maybe we can take advantage of the service worker context and extend in further? It is a free world after all. Within your entry service worker, let's introduce a new module export:

export const getLoadContext: GetLoadContextFunction = (event: FetchEvent) => {
  return {
    logger,
  }
}

The getLoadContext allows for creating the context object. It takes in the fetch event object and returns an object. Providing the event and fetchFromServer default properties aren't necessary as the runtime handles the merging for us, if we provide it though, it overrides the defaults.

// this is just an example, don't do this
export const getLoadContext: GetLoadContextFunction = (event: FetchEvent) => {
  const noop = () => {}
  return {
    fetchFromServer: noop // now, the function does nothing across our app
  }
}

The context object isn't typed beyond the default properties so you would need to provide custom typings when extending via getLoadContext

Going back to our route, we can replace console with logger from the context and voila! We are using the logger instantiated in our service worker file.

Enough touring. Let's get back to queueing now that we understand route worker apis better.


Cleaning up our workerAction, we end up with this:

type ExtendedContext = WorkerLoadContext & { logger: Logger }

export const workerAction = async ({ context }: WorkerActionArgs) => {
  const { fetchFromServer, logger } = context as ExtendedContext

  let response;

  try {
    response = await fetchFromServer()
  } catch {
    logger.error('failed to fetch!')
    response = new Response('Failed to fetch', { status: 500 })
  }

  return response
}

We wrapped the fetch in a try...catch wrapper to avoid errors due to connectivity bubbling up and spilling uncontrollably. By adding a catch block, we direct our app to log the error and also return a server error response (go offline, update a todo and then check the console).

Let's walk through how queueing tasks work:

  • First thing is actually detecting situations where background sync is needed. For example, offline conditions or poor network. This can happen when a user tries to send a message, upload a file, or synchronize data while offline.

  • Next up is to register a background sync queue. This creates an empty queue that you can then add requests too later

  • When a background sync event is registered, the browser adds it to a queue of pending sync events associated with your PWA. The tasks in this queue are persisted, ensuring they survive browser restarts or device reboots.

  • When the device regains a stable network connection, the browser automatically triggers the background sync event registered earlier. At this point, your service worker can handle the event through the sync event listener and execute the queued tasks.

  • If the network operation fails or encounters an error, your service worker can attempt to retry the task or handle the error appropriately.

That's the gist of it. Via @remix-pwa/sync, it covers a lot of the nuances for us. In our main service worker, let's add the following:

import { BackgroundSyncQueue } from "@remix-pwa/sync";

const todoQueue = new BackgroundSyncQueue('update-todo', {
  logger,
  maxRetentionTime: 60 * 24,
})

This creates a queue with an ID of "update-todo", and a maximum retention time of 1 day (in minutes). We also provided our logger to keep the debugging consistent. To make this queue available universally, we would need to provide a way to pass it around our service worker. If you guessed via context, you are right!

export const getLoadContext: GetLoadContextFunction = () => {
  return {
    logger,
    queue: todoQueue
  }
}

We have added a new member to our context: queue.

Back to our /$todoId route, let's make a few changes to the catch block. This is where we would be queueing a request if it does fail.

type ExtendedContext = WorkerLoadContext & { logger: Logger, queue: BackgroundSyncQueue }

export const workerAction = async ({ context }: WorkerActionArgs) => {
  const { fetchFromServer, queue, event } = context as ExtendedContext

  let response;

  try {
    response = await fetchFromServer()
  } catch {
    await queue.pushRequest({
      request: event.request,
    })

    response = Response.error()
  }

  return response
}

Let's check out our catch block, we added a queue.pushRequest invocation, that sets up a request for future retries if it fails. We still return an error response.

The pushRequest method can take in further options like metadata to provide extra context/details to the request that can then be used when the requests are "replayed" (when they get re-attempted by the service worker). Let's take a look at our sync in action

After a bit of to-and-fro navigation, turn off your network in the browser (via the "Network" or "Network conditions" tab) and after navigating to an already created todo, attempt to update it. Nothing happens on the UI, but if you open up your console, you get a message:

Request for '/todo-id?_data=routes%2F_todos.%24todoId' has been added to background sync queue 'update-todo'.

What this means, is that our todo has been marked and stored. If we go back online, we get three new logs: One informing us that our sync tag ('update-todo') has been received. The next one tells us which requests have been replayed (all of them) and the last one informs us that all our queue are empty (because they have successfully gone through). If, by chance, some of the replays failed, we would be notified, and the request would be put back into the queue.

Refresh the page to see the updates in place!

πŸ—£οΈ UI Feedback

This section can't be overstated. Providing the experience and redundancies isn't enough, the user must always be kept in the loop of what's happening. We would be implementing a few and I would also provide a few pointers for further improvements that can be made.

The first enhancement to make is the toggle feedback, when a user is offline and toggles a checkbox, the loader revalidates which returns the cached to-do list. In other words, the to-do list without our user changes. We could modify our code to only reset our local state when the user is online; that means when a user is offline and revalidation occurs, the cached to-do list never overrides our local state.

To handle the network part easily, let's install @remix-pwa/client. This is a package that helps with everything client utilities; from contact picker to user agent detection to network connectivity. It is quite a versatile package. Run npm install @remix-pwa/client to install and get started with it. We would be making use of the useNetworkConnectivity hook and doing checks in our effect:

const isOnline = useNetworkConnectivity()

useEffect(() => {
  if (isOnline) {
    setTodos(listTodos)
  } else {
    return;
  }
}, [listTodos])

We are doing a basic check for the user connectivity status and if the user is online and the loader revalidated, we update our local state else we do nothing.

If we check this out, toggling doesn't provide any flash effect but works as expected. If you also check the logs, the request has been queued. When we come back online, the requests are replayed and refreshing the page seems makes no difference UI-wise. This is fun πŸŽͺ!

This works for simple use-cases but in bigger and more elegant scenarios, we can employ workerLoader, clientLoader, clientAction and session storage to provide a more robust experience. By updating the browser storage in our client action, we can provide a temporary store that the client loader can then utilise if the request fails. We can use the workerLoader to provide a "failed request" that doesn't break the site.

Since, we are already using the useNetworkConnectivity hook, let's go ahead and create a simple toast that alerts the user when they go offline or come back online.

type Toast = {
  title: string
  body: string
}

const [toast, setToast] = useState<Toast | null>(null)

const isOnline = useNetworkConnectivity({
    onOnline: () => {
      setToast({ title: 'Online', body: 'Thank Goodness! We are back online' })
    },
    onOffline: () => {
      setToast({ title: 'Offline', body: 'Oh no! You are offline' })
    }
})

useEffect(() => {
  if (toast) setTimeout(() => setToast(null), 3000)
}, [toast])

return (
  <div className="todo-content todo-id-content">
      {toast && 
        <div className="toast">
          <h2>{toast.title}</h2>
          <p>{toast.body}</p>
        </div>
      }
  // rest of our component...
)
.toast {
  position: fixed;
  top: 16px;
  right: 16px;
  padding: 12px 16px;
  border-radius: 4px;
  background-color: #121212;
  color: #fff;
  z-index: 99;

  & h2 {
    font-size: 18px;
    margin-bottom: 8px;
    font-weight: 500;
  }

  & p {
    font-size: 16px;
  }
}

This is a DIY toast, please use an actual toast library or a more elegant solution when building. But this works anyway. The useNetworkConnectivity hook takes in two callbacks for offline and online events respectively. Using that we provide a simple alert for when a user connection state changes. To test it out, simply toggle the network in the "Network" tab.

That's all enhancements for this section. There are a few more we could explore for the other routes but I would be leaving that up to you. @remix-pwa/client is quite massive when it comes to utilities to spice up PWAs, I am sure further exploration would reveal some goodies right up your alley ✌️.

🧹 Cleanup

In this section, we would be re-visiting something we've left off for a while. Cache pruning. What that means is that when a user logs out, we want to clear out the cache. This is mainly for security reasons as we don't want another user to be able to view a user's data after logging out; sensitive or not.

There are multiple ways to go about this. We could create a workerLoader in /logout (yes, they also work in resource routes) and then clear out the caches there. Or in our defaultFetchHandler, we simply add another if clause (if (url.pathname === '/logout) and handle it there. It's up to us. I prefer the former.

We would be passing our caches to the global context and accessing them from within our worker loader

You must be wondering why we are passing the cache around, when we could directly access the cache via the Cache API and clear it from there. The answer is showcasing more features from Remix PWA. You would see πŸ˜‰...

export const getLoadContext: GetLoadContextFunction = () => {
  return {
    logger,
    queue: todoQueue,
    documentCache,
    dataCache,
  }
}

We are just going to be using the document and data cache for this one. In our logout route:

type ExtendedContext = WorkerLoadContext & { 
  logger: Logger,
  queue: BackgroundSyncQueue
  documentCache: EnhancedCache,
  dataCache: EnhancedCache,
}

export const workerLoader = async ({ context }: WorkerLoaderArgs) => {
  const { fetchFromServer, documentCache, dataCache } = context as ExtendedContext

  documentCache.clearCache()
  dataCache.clearCache()

  return await fetchFromServer()
}

If we now log out, everything happens as expected. But on checking the cache in the "Application" tab, we realise both data and document cache are empty. Eazy Peazy.

Why did we have to share the cache across the context again? That's right, to further customise the cache deletion. Imagine you have a bigger application with multiple routes, several of them not tied to authorization state (e.g. landing page, blog and docs) and some others requiring authentication (dashboard, user settings, etc.), clearing the cache for such applications is a stretch too far. So, what we want to do instead is selective "pruning" (deletion).

In our workerLoader, let's do some selective deletion for our cache:

export const workerLoader = async ({ context }: WorkerLoaderArgs) => {
  const { fetchFromServer, documentCache, dataCache } = context as ExtendedContext

  EnhancedCache.purgeCache(documentCache, ({ request, response }) => {
    if (response?.status === 200) {
      return true
    }

    return false
  })

  EnhancedCache.purgeCache(dataCache, ({ request, response }) => {
    const url = new URL(request.url)
    const params = new URLSearchParams(url.searchParams)

    if (params.has('_data') && params.get('_data')?.includes('_todos')) {
      return true
    }

    return false
  })

  return await fetchFromServer()
}

This looks a bit complicated. What's happening?

First and foremost, EnhancedCache ships with a few static methods that can be applied to any cache (enhanced cache instance). One of which is the purgeCache method: an awesome utility that allow you to fine-grain control over how entries are removed from cache. It takes in two arguments, the cache itself and a filter callback that returns a boolean.

For our document cache, we are simply deleting any successful response. This was to showcase that responses can be used as a criterion when filtering out the cache.

The data cache uses a more sophisticated, yet simple approach. Remix loaders and actions requests are always appended with a _data parameter. This parameter takes in the route id as a value. Any loader or action request (which is our API responses) made to a sub-route of the /_todos pathless route (our authenticated routes) are matched and deleted.

If we go back to our application and try to log back in and log out again, it seems like nothing changed. That's because all entries in our cache matched. If we expanded our application, we would notice just the required entries are deleted whilst the rest are perfectly fine.

That's it for this section!

πŸ“² Installability

Our PWA isn't a PWA if it isn't installable. That's what web manifests are for!

Let's scaffold one quickly by running:

npx remix-pwa manifest

This creates a new quick manifest in our routes. We need to register it in our html (via <link>) before it is useful though. @remix-pwa/sw provides a component to achieve that in a jiffy. In our app's root, just above the <Links /> component, let's add the <ManifestLink /> component.

import { ManifestLink } from "@remix-pwa/sw";

// in our layout
<head>
  <meta charSet="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <Meta />
  <ManifestLink manifestUrl="/manifest.webmanifest" />
  <Links />
</head>

We are passing in our manifest URL which is located at /manifest.webmanifest. If you use another name/route, ensure to update it here.

Let's open our manifest and review what was created. It is a resource route that returns a simple JSON object with TypeScript typing enabled. The Content-Type header is application/manifest+json to correctly infer the content we are returning. That's the main gist of it. We would be doing some heavy editing so let's get editing πŸ–ŒοΈ!

First and foremost, this isn't a valid manifest. If we open up the "Manifest" tab in the "Application" tab within our browser's dev tools, we notice a few warnings pop up. The most important one about missing icons. What should your device display as the app's icon when installed? Instead of worrying about sourcing icons, you can easily generate some random icons via Real Favicon Generator.

After downloading the icons (in a ZIP format), we extract the downloaded files to the public folder. Delete the site.webmanifest, mstile-150x150.png and browserconfig.xml files. We won't be using those. Let's also add a few meta tags to our root head:

<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#121212" />
<meta name="apple-mobile-web-app-title" content="Todoist" />
<meta name="application-name" content="Todoist" />

This is basically for sprucing up the PWA. It doesn't have anything to do with the manifest.

Now that we have our icons, let's go and make some changes to our manifest:

return json(
    {
      short_name: "Todoist",
      name: "Todoist",
      description: "A simple todo application.",
      scope: "/",
      icons: [
        {
          src: "/android-chrome-192x192.png",
          sizes: "192x192",
          type: "image/png",
        },
        {
          src: "/android-chrome-512x512.png",
          sizes: "512x512",
          type: "image/png",
        },
      ],
      start_url: "/",
      display: "standalone",
      background_color: "#121212",
      theme_color: "#121212",
    } as WebAppManifest,
    //...
)

A simple yet working manifest. If we head back to the browser, we see an "Install Todoist" button at the top end of our URL search bar. If we click on it, a basic prompt comes up:

Let's go ahead and customise this further. Instead of relying on the default (and ugly) install flow, we want to instead provide our own flow: Prompt the install when the user clicks a custom button.

Installation in the browser is handled via the beforeinstallprompt event. This event is (usually) fired on page load when a PWA meets some certain criteria. What are the criteria, you ask?

The most important one is the presence of a web manifest with:

  • A name or short_name attribute. Can't go around installing nameless apps.

  • icons - must include a 192px and a 512px icon

  • start_url - this represents the start URL of the web application β€” the preferred URL that should be loaded when the user launches the web application (e.g., when the user taps on the web application's icon from a device's application menu or homescreen)

  • display and/or display_override

It must also not be installed already. Should be served over HTTPS. And lastly, the user must have interacted with the PWA for half a minute (at any time) including clicking or tapping at least once.

These are the main criteria needed for the beforeinstallprompt to fire. There might be a few varying details depending on the browser, but it is pretty straightforward across all of them. On mobile, this event causes that tiny pop up to show. Allowing the user to install your app with a single tap.

In order to customise it, we hijack the process! The event is fired on the browser window:

window.addEventListener('beforeinstallprompt', (e) => {
    //...
});

To handle the prompt ourselves, we do a few things. The first thing is to prevent the event's default behaviour from executing: via preventDefault()

window.addEventListener('beforeinstallprompt', (e) => {
  // Prevent the mini-infobar from appearing on mobile
  e.preventDefault();
  // ...
});

The event has a prompt property that allows you to prompt the installation. We save that event elsewhere

let deferredPrompt;

window.addEventListener('beforeinstallprompt', (e) => {
  // Prevent the mini-infobar from appearing on mobile
  e.preventDefault();
  // Stash the event so it can be triggered later.
  deferredPrompt = e;
});

Using the deferred event, we can then trigger the prompt manually at a later point:

const installPwa = () => {
  deferredPrompt.prompt()
}

In Remix PWA, this ability is implemented within the usePWAManager hook available in @remix-pwa/client. Let's go ahead and implement it.

Within our routes/_todos.tsx route, let's expand the page header:

export default function Component() {
  const { promptInstall } = usePWAManager()

  return (
    <div className="todo-page">
      <nav className="todo-header">
        <Link to={'/todos'}>Todoist</Link>
        <div>
          <button onClick={promptInstall}>Install</button>
          <Link to='/logout'>Logout</Link>
        </div>
      </nav>
      <Outlet />
    </div>
  );
}

You should prompt the install on user interaction (click, hover, drag, etc.)

Because it wouldn't make sense to still render the install button even after the user has installed the app, let's render the button conditionally.

export default function Component() {
  const { promptInstall, userInstallChoice } = usePWAManager()

  return (
    <div className="todo-page">
      <nav className="todo-header">
        <Link to={'/todos'}>Todoist</Link>
        <div>
          {(!userInstallChoice || userInstallChoice !== 'accepted') && <button type="button" onClick={promptInstall}>Install</button>}
          <Link to='/logout'>Logout</Link>
        </div>
      </nav>
      <Outlet />
    </div>
  );
}

Another property of the beforeinstallprompt event is userChoice. The userChoice could either be accepted, rejected or undefined if the prompt hasn't been triggered yet.

The choice is accessible via the userInstallChoice property of the PWA Manager hook.

Now it works as expected πŸ™Œ!

We could go even further and customise the standalone window. But that would be for another guide. PWA potentials are quite endless πŸš€

That would be all we would be doing for Installability though, let's go ahead and explore potential pitfalls as well as security points to consider when transforming your app into a PWA.

πŸ”’ Security

Like everything in life, security is a paramount issue for Service Workers and browsers as well. Thanks to Google, Project Fugu and other giants championing the inclusion of PWAs into modern web trends, a lot of security issues have been mitigated and accounted for. There are a few things to take note of and keep in mind, and also security implications to be made aware of when building for your users; After all, a service worker is a handgun that can be used to shoot yourself or dummy targets.

Here are some pointers and considerations regarding security in PWAs and service workers:

  • Cross-Site Scripting (XSS) Protection: Service workers can be vulnerable to XSS attacks if they handle user input carelessly. Just like how the server is mandated to validate incoming data, the service worker (which can be seen as an extension to your server) should also ensure that risks are handled and attacks curbed.

  • Cross-Origin Requests: Service workers are bound by the same-origin policy, meaning they can only intercept and modify requests originating from the same origin as the service worker itself. However, they can still make cross-origin requests using the fetch API, subject to CORS (Cross-Origin Resource Sharing) restrictions.

  • HTTPS Requirement: Service workers can only be registered on pages served over HTTPS (with some exceptions for localhost during development). This is a security measure to prevent man-in-the-middle attacks and ensure the integrity of the service worker script.

  • Opaque Responses: When a service worker intercepts a request and encounters a response with an opaque origin (e.g., a cross-origin request without CORS headers), it cannot read or modify the response body. This is a security measure to prevent sensitive data from being leaked across origins. However, service workers can still cache opaque responses and serve them later. In Remix PWA, this is disabled by default.

  • Secure Caching: Service workers should carefully manage what data they cache and how they handle cached responses. Avoid caching sensitive data or user-specific information that could lead to security vulnerabilities or privacy concerns.

  • Content Security Policy (CSP): Consider implementing a Content Security Policy (CSP) to restrict the resources your PWA can load and the actions it can perform. This can help mitigate various types of attacks, such as cross-site scripting (XSS) and code injection vulnerabilities.

  • Secure Communication: When communicating between the service worker and the main application thread, use secure messaging channels like MessageChannel or BroadcastChannel to prevent potential eavesdropping or tampering.

That's some of the main ones to keep in mind. I can't possibly go over all of them (even if I tried), but this provides a good starting point on what to look out for when planning and securing your PWA.

✨ SEO & Final Touches

We are coming to the end of this guide! Hopefully, you've learned a lot since the beginning and are ready to start exploring on your own. Before you run off though, let's handle the very last bits, clean our app up and deploy it to the world. I mean, how good is our app if nobody ever criticises (or even uses) it?

This paragraph is a pause paragraph. If you haven't taken a break since the start of this guide, consider taking one now. Stretch, get yourself another cup of tea, walk about, stare off into the distance, do anything else. After doing that, let's freestyle πŸŽ†! Make Todoist your own (I would love to see how far you can transform this concept), add extra features: editing todos, finer checkboxes, better layout, a landing page, go wild.


Now that you've (hopefully) gone above and beyond with your PWA, let's wrap things up. The first thing is SEO (Search Engine Optimization). No matter how feature-rich and performant our PWA is, it won't be truly successful unless users can easily discover and access it. This is where Search Engine Optimization (SEO) comes into play – the practice of optimizing our web application to rank higher in search engine results, making it more visible and accessible to potential users.

export const meta = () => {
  return [
    { title: "Todoist" },
    {
      property: "og:title",
      content: "Todoist",
    },
    {
      name: "description",
      content: "A Remix Todo PWA. Quite a beautiful app",
    },
  ]
}

Not too shabby. This export is placed in our root route and provides a title and description for Todoist. You can go ahead and add more meta items like preview images.

That's it. I originally intended to also include a section on protocol handling - the ability to handle custom protocols (like web+pwa://), the file system or device mail - in this guide but decided to save it for another day. Now, let's deploy this thing!

🚚 Deployment

Deploying your Remix Progressive Web App (PWA) is the final step in the journey, and it's essential to ensure a smooth and efficient process. In this section, we would be deploying our application to the web and native devices at large. No, we won't be deploying to App Stores in this guide, but we will be doing something quite close.

I won't be touching on the full specifics of deployment, which we will be handling via fly.io, but you can check out this guide to get started with Remix on Fly.

We run fly launch to kickstart the deployment process. After configuring our apps' deployment settings, we can go ahead and deploy.

Our app is now live! But we aren't done. No, we are far from done. It's a PWA after all! We won't be deploying to app stores in this guide, but we would be doing something pretty close. First, let's head over to PWABuilder.com.

As you might have guessed, this site would help us package our simple PWA into signed (meaning certified as secure) executables that can then be distributed across app stores and native devices.

Using the URL of our application, mine is: https://simple-todoist-app.fly.dev, we are navigated to a page that audits our PWA and gives suggestions as well as warnings.

If you are building a real app, I advise you to take these suggestions seriously. For now, though? Ignore them. We are going for simplicity over functionality.

Let's click on "Package for Stores". This brings up a much smaller modal that allows us to select which platform to build for. I use an Android device (Pixel πŸ’–) and would select: "Generate Package" for Android. We now have the option to generate a signed (Google Play) or un-signed APK. We can leave the settings as is (without editing anything) and download our package. This is a zip file containing our PWA's APK (which we can distribute), a special JSON file (assetlinks.json) and a few other files that we won't be utilizing in this guide.

If you are interested in actually publishing to the Google Play Store, check out this guide (available in the ZIP file too)

What makes assetlinks.json special? It's a JSON file hosted on your website that establishes a verified association between your PWA (site) and your Android app. By deploying this file with your app, you're essentially telling Google that your PWA and Android app are related and should be treated as a cohesive entity (meaning they are one).

The assetlinks.json file contains a set of statements that describe the relationship between your PWA and Android app. These statements include information such as the website domain, the package name of the Android app, and cryptographic digests that verify the authenticity of the app. When a user searches for your app or website on Google, the search engine can use the information provided in the assetlinks.json file to determine the connection between the two and potentially surface both the PWA and the Android app in the search results.

Furthermore, the assetlinks.json file plays a crucial role in enabling features like Android App Banners and Trusted Web Activity (TWA). App Banners allow the Android app to be promoted from the PWA, while TWAs enable your PWA to be launched and run as a standalone app-like experience on Android devices.

That was quite a mouthful πŸ˜‘

To enable all these, we would need to host our assetlinks.json file (that means another deployment). In your public folder, create a .well-known folder and copy your assetlinks.json into it. After that we run: fly deploy --remote-only to trigger another deployment.

With the deployment up and running, we can now share the APK provided by PWABuilder to family, friends and colleagues (as long as the OS supports side-loading, this should work). We would be exploring deploying to and sharing via official app stores in another guide.

πŸ’–πŸ™ŒπŸš€!

🎁 Bonus Section

This section contains a few tips, and tricks that didn't make their way into the original guide.

  • npx remix-pwa update|upgrade: This command is a short-hand provided by Remix PWA to quickly update all your available Remix PWA packages at one glance. On execution, it detects your Remix PWA packages and takes care of them. If you want a fine-tuned upgrade, upgrading one package instead of all, you can opt into that as well: npx remix-pwa update -p sync - updates just @remix-pwa/sync. Read up on it here.

  • EnhancedCache provides some extra utilities not mentioned in the guide. Two very useful ones are the compressResponse and decompressResponse. They take in just the response as an argument, allowing you to compress and decompress responses and save space on bigger responses

  • I recommend building out the main skeleton of your application before integrating PWA features into it. Aside from allowing you to focus on one thing at a time, it makes it easier to debug. As powerful as PWAs are, they're one of the nastiest things (in my opinion) to debug.

  • As well-rounded as strategies are in Remix PWA, sometimes they aren't enough for some use cases. In that case, extending the BaseStrategy class and implementing your own strategy is the way to go. More info.

  • Even though the subject of precaching wasn't touched on within this guide (at all 😀), it is a good thing to implement within your application. Precaching means you cache a resource ahead of its time. Allowing you to speed up otherwise slow blockers within your app (a good example of this is images, fonts and videos).

  • Do you know you can detect node environments directly within your service worker? Yup, that's right. You can choose to do something conditionally based on whether the service worker is being run in development or production (that's how logger works under the hood πŸ‘€)

  • workerLoader never runs on the first render. 🀐.

  • You can't cache non-GET requests. the Cache API is reserved for only GET requests. If you want to store non-GET requests (for whatever reason), you should use another browser API like the trusty IndexedDB.

  • You can provide a universal try...catch wrapper for worker route APIs via the errorHandler exported from your entry worker file.


That's it for this guide 🍾! You have persevered all the way to the end and learned some new things too. Now, you can head out into the wild of the Wide World Web and start building fancy web applications that can run natively. This guide is just the tip of the iceberg though, there are a lot more elements that weren't brushed upon in this guide. Including Push API, versioning, custom window, etc. My goal is to release tidbit guides for some of the more basic concepts and long guides for standalone concepts (like Push).

Fun fact: I named this guide A-Y, instead of the usual A-Z, because this guide is aimed at teaching the basics, the A-Y, whilst you bring the Z with your own unique creative flair πŸ˜‰.

The full source code for this guide is available here. I have added a few extra styling and touches, but it's still quite plain.

I have been exploring audio and video support in PWAs, would be fun to build a mini-SoundCloud clone. Or a chat system, that would help to showcase Push API in full. Just throwing ideas out loud here, if you are still here you could chip in too. See you in the comments!

If you want to go the extra mile and support me and my writing (πŸ’ thank you), I have a public Github Sponsors profile. If you have any suggestions, tips or corrections for this guide, please comment it down below. Till next time πŸ‘‹

Β