Wordle in Remix: Part 7 - Protected Routes

Wordle in Remix: Part 7 - Protected Routes

This is the seventh article in a series where we create a Wordle clone in Remix! 💿 We go step by step, from setting up the application to creating pages and implementing game logic. In this article, we will be adding protections to selected routes in our application to make sure the users can't access some pages too early.

The problem

I'm almost certain you noticed that at the moment you can rather easily get to know the word without guessing. Just navigate to either /play/win or /play/loss and just see the word right there. That's no good. 💩

We have to add necessary protections to the routes to make sure that we can only see the winning pop-up when the user has won the game, and the loss pop-up when they've lost.

Protected routes

There are probably several ways you could go about ensuring that the routes are accessible only under certain conditions in Remix. For us, the protections revolve around accessing the game state. 🍪

Let's take a look at the winning pop-up as an example. The plan is as follows.

  1. When entering the page and reading the session cookie, check the game status.
  2. If it's win, let the user continue and show the page.
  3. Otherwise, redirect back to the /play page.

We know already that to run any logic when a user enters a page in Remix we can use a loader function.

Implementation

We've laid out the plan, and what's left is to turn this into code.

// app/routes/play/win.tsx
export const loader: LoaderFunction = async ({ request }) => {
  const session = await getSession(request.headers.get("Cookie"));
  const status = session.get("status");

  if (status !== "win") {
    throw new redirect("/play");
  }

  return json(
    { word: session.get("word") },
    { headers: { "Set-Cookie": await commitSession(session) } }
  );
};

Notice that in the if block we throw the response - in this particular case, it's just following the convention, to indicate that we're breaking out of the typical flow.

There are also functional implications to throwing inside loaders and you can read more about them in the documentation.

Now, let's add a similar block to the loss pop-up page.

// app/routes/play/loss.tsx
export const loader: LoaderFunction = async ({ request }) => {
  const session = await getSession(request.headers.get("Cookie"));
  const status = session.get("status");

  if (status !== "loss") {
    throw new redirect("/play");
  }

  return json(
    { word: session.get("word") },
    { headers: { "Set-Cookie": await commitSession(session) } }
  );
};

With that in place, try when you try to navigate to the page you shouldn't have access to at the moment, you're redirected back. Sweet! 👌

Abstracting protection logic

There's one more thing we can do. You might have noticed that there's a bit of duplication around the protection logic. We could rephrase our access protection rule to the following.

Require a given game status to read the session cookie. If that requirement is not met, redirect back.

Let's add a helper function in sessions.ts that will reflect this statement.

// app/sessions.ts
import { redirect } from "remix";
import { GameStatus } from "./types";
...
export async function requireSessionStatus(
  request: Request,
  requiredStatus: GameStatus
) {
  const session = await getSession(request.headers.get("Cookie"));
  const status = session.get("status");

  if (status !== requiredStatus) {
    throw new redirect("/play");
  }

  return session;
}

Now, we can replace the code added in loaders with the following.

// app/routes/play/win.tsx
import { requireSessionStatus } from "~/sessions";
...
export const loader: LoaderFunction = async ({ request }) => {
  const session = await requireSessionStatus(request, "win");

  return json(
    { word: session.get("word") },
    { headers: { "Set-Cookie": await commitSession(session) } }
  );
};
// app/routes/play/loss.tsx
import { requireSessionStatus } from "~/sessions";
...
export const loader: LoaderFunction = async ({ request }) => {
  const session = await requireSessionStatus(request, "loss");

  return json(
    { word: session.get("word") },
    { headers: { "Set-Cookie": await commitSession(session) } }
  );
};

This way we extracted the protection logic out of the routes and got rid of the duplication. 🥳

Conclusion

Great, we've added necessary protections not to ruin all the fun for the user! ✨ Here's a short summary of things went through:

  • explained the problem with route access
  • implemented access restrictions for pop-up routes
  • extracted the protection logic to a separate session getter

Next up, we will improve the user experience, by changing how we handle client-server communication in our application. See you! 👋

If you liked the article or you have a question, feel free to reach out to me on Twitter‚ or add a comment below!

Further reading and references