Skip to content

Protecting routes

Now that we have a fully functioning admin review page (see our previous guide), we need to make sure that unauthorised users can’t access it.

Because this page allows users to publish or unpublish articles, we need to ensure that only users with the β€˜admin’ role can access it.

In this guide, we will learn how to protect routes in Remix by using the loader function to check if a user has the β€˜admin’ role before they can access it.

We will do this across two stages:

  1. Forbid users from accessing the admin review page who are not logged in or do not have the β€˜admin’ role.
  2. Provide a link in the navbar to the admin review page only for users with the β€˜admin’ role.

This first step is surprisingly easy because the Epic Stack comes with functions that handle authentication and authorisation!

We therefore only need to add a few lines of code 😎.

Open app/routes/admin-review.tsx and make the additions shown below:

app/routes/admin-review.tsx
import { type LoaderFunctionArgs, json } from '@remix-run/node'
import { NavLink, Outlet, useLoaderData } from '@remix-run/react'
import { prisma } from '~/utils/db.server.ts'
import { cn } from '~/utils/misc.tsx'
import { requireUserWithRole } from '~/utils/permissions.server.js'
export async function loader({ request }: LoaderFunctionArgs) {
await requireUserWithRole(request, 'admin')
const allArticles = await prisma.article.findMany({
select: { id: true, title: true, isPublished: true },
})
return json({ allArticles })
}

The way to test this is to log in with the user role you set up for yourself in a previous tutorial.

Once you are logged in, try to access the 'http://localhost:3000/admin-review' page. You should see a 403 error:

Unhandled 403 error

This is fine, but not the best user experience. At this point, the application has completely crashed, and the only way a user can recover is to use the browser’s back button.

Let’s improve this by adding an ErrorBoundary component to catch the error and display a more user-friendly message.

Let’s add an ErrorBoundary component to app/routes/admin-review.tsx.

First, import the GeneralErrorBoundary and Button components:

app/routes/admin-review.tsx
import { type LoaderFunctionArgs, json } from '@remix-run/node'
import { NavLink, Outlet, useLoaderData } from '@remix-run/react'
import { prisma } from '~/utils/db.server.ts'
import { cn } from '~/utils/misc.tsx'
import { requireUserWithRole } from '~/utils/permissions.server.js'
import { Button } from '~/components/atoms/Button.js'
import { GeneralErrorBoundary } from '~/components/ErrorBoundary.js'

Next, scroll all the way to the bottom of the component file, and add the code below:

app/routes/admin-review.tsx
import { type LoaderFunctionArgs, json } from '@remix-run/node'
import { NavLink, Outlet, useLoaderData } from '@remix-run/react'
import { prisma } from '~/utils/db.server.ts'
import { cn } from '~/utils/misc.tsx'
import { requireUserWithRole } from '~/utils/permissions.server.js'
import { Button } from '~/components/atoms/Button.js'
import { GeneralErrorBoundary } from '~/components/ErrorBoundary.js'
export async function loader({ request }: LoaderFunctionArgs) {
7 collapsed lines
await requireUserWithRole(request, 'admin')
const allArticles = await prisma.article.findMany({
select: { id: true, title: true, isPublished: true },
})
return json({ allArticles })
}
12 collapsed lines
interface StatusPillProps {
isPublished: boolean
}
export function StatusPill({ isPublished }: StatusPillProps) {
return (
<div
className={`rounded-full px-2 py-1 text-xs font-semibold ${isPublished ? 'bg-green-700 text-white' : 'bg-red-700 text-white'}`}
>
{isPublished ? 'P' : 'D'}
</div>
)
}
export default function ArticlesRoute() {
40 collapsed lines
const { allArticles } = useLoaderData<typeof loader>()
const navLinkDefaultClassName =
'line-clamp-2 block rounded-l-full py-2 pl-8 pr-6 text-base lg:text-xl'
return (
<main className="container flex h-full min-h-[750px] px-0 py-12 md:px-8">
<div className="grid w-full grid-cols-4 bg-muted pl-2 md:container md:rounded-3xl md:pr-0">
<div className="relative col-span-1">
<div className="absolute inset-0 flex flex-col">
<ul className="overflow-y-auto overflow-x-hidden py-12">
{allArticles.map(article => (
<li
key={article.id}
className="flex items-center gap-2 p-1 pr-0"
>
<StatusPill isPublished={article.isPublished} />
<NavLink
to={article.id}
preventScrollReset
prefetch="intent"
className={({ isActive }) =>
cn(
navLinkDefaultClassName,
isActive && 'w-full bg-accent',
)
}
>
{article.title}
</NavLink>
</li>
))}
</ul>
</div>
</div>
<div className="relative col-span-3 bg-accent md:rounded-r-3xl">
<Outlet />
</div>
</div>
</main>
)
}
export function ErrorBoundary() {
return (
<GeneralErrorBoundary
statusHandlers={{
403: () => (
<div>
<p>You are not allowed to access this page.</p>
<p>
Please login with an administrator account, or contact support.
</p>
<Button>
<NavLink to="/login">Login</NavLink>
</Button>
</div>
),
}}
/>
)
}

Save your changes and head back to the browser. Try to access the 'http://localhost:3000/admin-review' page again while logged in with the same user.

You should now see a more helpful message:

Handled 403 error

In this step, we have:

  • Protected the admin review page by adding checking if the user has the β€˜admin’ role.
  • Added an ErrorBoundary component to catch the 403 error and display a more user-friendly message.