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.