Create an admin page
The news app is almost complete. We can write articles, view, edit, and delete them.
Our final job is to add an admin page that allows admin level users to publish articles when they are ready, and make them appear to visitors on the site.
Set up the admin page
Section titled โSet up the admin pageโFor the time being, we will make this page available to everyone. We can grant access to admin only users in the next guide.
-
Create the
Section titled โCreate the /admin-review routeโ/admin-reviewrouteThe first thing we need is a new parent route to display a list of all articles.
This is straightforward. Firstly, create a new file inside
app/routescalledadmin-review.tsx:
-
Add a
Section titled โAdd a loader functionโloaderfunctionIn
app/routes/admin-review.tsx, set up aloaderfunction that returns theid,titleandisPublishedvalues for each article:app/routes/admin-review.tsx import { data } from 'react-router'import { prisma } from '~/utils/db.server.ts'export async function loader() {const allArticles = await prisma.article.findMany({select: { id: true, title: true, isPublished: true },})return data({ allArticles })} -
Parent user interface
Section titled โParent user interfaceโWe can use the same layout as the
/users/$username/articlespage to display the list of articles.The only thing we need to add (on line 43) is an indication of whether the article is published or not.
This will be a small โpillโ user interface element. It will show a green โPโ for โPublishedโ or a red โDโ for โDraftโ.
Still inside
app/routes/admin-review.tsx, add the following code:app/routes/admin-review.tsx import { data, NavLink, Outlet, useLoaderData } from 'react-router'import { prisma } from '~/utils/db.server.ts'import { cn } from '~/utils/misc.tsx'export async function loader() {const allArticles = await prisma.article.findMany({select: { id: true, title: true, isPublished: true },})return data({ allArticles })}interface StatusPillProps {isPublished: boolean}export function StatusPill({ isPublished }: StatusPillProps) {return (<divclassName={`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() {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 => (<likey={article.id}className="flex items-center gap-2 p-1 pr-0"><StatusPill isPublished={article.isPublished} /><NavLinkto={article.id}preventScrollResetprefetch="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>)}Save your changes and head to
http://localhost:3000/admin-reviewin the browser.At first, of course, all the articles will show a red โDโ to signify โDraftโ:

As we update the articles, the status will change to a green โPโ for those that we mark as โPublishedโ.
-
Create a child index route
Section titled โCreate a child index routeโIโll also create a new child index route file at
app/routes/admin-review._index.tsx:
This will simply display a message asking the user to select an article to review.
Add the code below to the new file:
app/routes/admin-review._index.tsx export default function ArticlesIndexRoute() {return (<div className="container pt-12"><p className="text-body-md">Select an article to review...</p></div>)}Refresh your page in the browser.
Your
/admin-reviewroute should look something like this:
This looks good, but of course if I click on any of the articles titles, Iโll get a 404 error because I havenโt created the child route yet.
Let fix that now.
-
Server-side code
Section titled โServer-side codeโIf I add a
.serversuffix to a route file name, this will make it a server route.Create a new file at
app/routes/__admin-review.$articleId.server.tsx:
Once set up, add the code below to it:
app/routes/__admin-review.$articleId.server.tsx import { parseWithZod } from '@conform-to/zod'import { invariantResponse } from '@epic-web/invariant'import { formatDistanceToNow } from 'date-fns'import {data,type LoaderFunctionArgs,type ActionFunctionArgs,} from 'react-router'import { z } from 'zod'import { prisma } from '~/utils/db.server.js'import { redirectWithToast } from '~/utils/toast.server.js'export async function loader({ params }: LoaderFunctionArgs) {invariantResponse(params.articleId, 'No article ID provided', { status: 404 })const article = await prisma.article.findUnique({22 collapsed lineswhere: { id: params.articleId },select: {id: true,title: true,content: true,ownerId: true,updatedAt: true,publishedAt: true,isPublished: true,category: {select: {id: true,name: true,},},images: {select: {objectKey: true,altText: true,},},},})10 collapsed linesinvariantResponse(article, 'Not found', { status: 404 })const updatedAtDate = new Date(article.updatedAt)const publishedAtDate = article.publishedAt? new Date(article.publishedAt): nullconst timeAgoUpdated = formatDistanceToNow(updatedAtDate)const timeAgoPublished = publishedAtDate? formatDistanceToNow(publishedAtDate): nullreturn data({article,timeAgoUpdated,timeAgoPublished,})}export const PublishArticleSchema = z.object({publish: z.optional(z.string()),unpublish: z.optional(z.string()),})export async function action({ request }: ActionFunctionArgs) {const formData = await request.formData()const submission = await parseWithZod(formData, {22 collapsed linesschema: PublishArticleSchema.superRefine(async (data, ctx) => {if (!data.publish && !data.unpublish) returnconst article = await prisma.article.findUnique({select: { id: true },where: { id: data.publish ?? data.unpublish },})if (!article) {ctx.addIssue({code: z.ZodIssueCode.custom,message: 'Article not found',})}}).transform(async (data) => {return {...data,publishedAt: new Date(),isPublished: data.publish ? true : false,}}),async: true,})if (submission.status !== 'success') {return data({ result: submission.reply() },{ status: submission.status === 'error' ? 400 : 200 },)}const { publish, unpublish } = submission.valueconst updatedArticle = await prisma.article.update({where: { id: publish ?? unpublish },data: {isPublished: publish ? true : false,publishedAt: publish ? new Date() : null,},})return redirectWithToast(`/admin-review/${updatedArticle.id}`, {type: 'success',title: 'Success',description: `The article has been ${publish ? 'published' : 'unpublished'}`,})} -
Create the
Section titled โCreate the /admin-review/$articleId routeโ/admin-review/$articleIdrouteIโll now create a new child route that will display the full article, together with a button that will toggle between saying โPublishโ or โUnpublishโ, depending on the articleโs status.
Again, Iโll start by creating a new file at
app/routes/admin-review.$articleId.tsx:
Next, Iโll add the code.
This is a long snippet, so take the time to read through the explanation beneath it carefully and understand what it is doing.
If you need extra help understanding the code, just ask and we can go through it in class together.
Here is the full code for the
app/routes/admin-review.$articleId.tsxroute file:app/routes/admin-review/$articleId.tsx 11 collapsed linesimport { getFormProps, useForm } from '@conform-to/react'import { Form, useActionData, useLoaderData } from 'react-router'import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx'import { type loader } from './admin-review.$articleId.server.tsx'import { floatingToolbarClassName } from '~/components/floating-toolbar.tsx'import { ErrorList } from '~/components/forms.tsx'import { Icon } from '~/components/ui/icon.tsx'import { StatusButton } from '~/components/ui/status-button.tsx'import { getArticleImgSrc, useIsPending } from '~/utils/misc.tsx'export { loader, action } from './admin-review.$articleId.server.tsx'export default function AdminReviewRoute() {const data = useLoaderData<typeof loader | null>()return (<div className="absolute inset-0 flex flex-col px-10">55 collapsed lines<h2 className="text-h2 mb-2 pt-12 lg:mb-6">{data?.article.title}</h2><div className="mb-4 flex justify-between gap-4"><p className="bg-card text-card-foreground w-fit rounded-lg px-4 py-2 text-sm">{data?.article.category?.name ?? 'General News'}</p><pclassName={`w-fit rounded-lg ${data?.article.isPublished ? 'bg-green-800' : 'bg-destructive'} text-card-foreground px-4 py-2 text-xs font-bold`}>{data?.article.isPublished ? 'Published' : 'Awaiting review'}</p></div><div className="overflow-y-auto pb-24"><ul className="flex flex-wrap gap-5 py-5">{data?.article.images.map((image) => (<li key={image.objectKey}><a href={getArticleImgSrc(image.objectKey)}><imgsrc={getArticleImgSrc(image.objectKey)}alt={image.altText ?? ''}className="h-32 w-32 rounded-lg object-cover"/></a></li>))}</ul><p className="text-sm whitespace-break-spaces md:text-lg">{data?.article.content}</p></div><div className={floatingToolbarClassName}><span className="text-foreground/90 text-sm max-[524px]:hidden"><Icon name="clock" className="scale-125">{data?.timeAgoUpdated} ago</Icon></span><spanclassName={`text-sm ${data?.timeAgoPublished ? 'text-foreground/90' : 'text-red/90'} max-[524px]:hidden`}><Iconname={data?.timeAgoPublished ? 'check' : 'update'}className="scale-125">{data?.timeAgoPublished? `Published ${data?.timeAgoPublished} ago`: 'Not published yet'}</Icon></span><div className="grid flex-1 grid-cols-2 justify-end gap-2 min-[525px]:flex md:gap-4"><ArticleStatusFormid={data?.article.id ?? ''}isPublished={data?.article.isPublished ?? false}/></div></div></div>)}interface PublishArticleProps {id: stringisPublished: boolean}export function ArticleStatusForm({ id, isPublished }: PublishArticleProps) {const actionData = useActionData<typeof action>()const isPending = useIsPending()const [form] = useForm({id: 'set-article-status',lastResult: actionData?.result,})return (<Form method="POST" {...getFormProps(form)}>23 collapsed lines<inputtype="hidden"name={isPublished ? 'unpublish' : 'publish'}value={id}/><StatusButtontype="submit"name="intent"value="set-article-status"status={isPending ? 'pending' : (form.status ?? 'idle')}disabled={isPending}className="w-full max-md:aspect-square max-md:px-0"><Iconname={isPublished ? 'cross-1' : 'check'}className="scale-125 max-md:scale-150"><span className="max-md:hidden">{isPublished ? 'Unpublish' : 'Publish'}</span></Icon></StatusButton><ErrorList errors={form.errors} id={form.errorId} /></Form>)}export function ErrorBoundary() {return (<GeneralErrorBoundarystatusHandlers={{403: () => <p>You are not allowed to do that</p>,404: () => <p>No article with the id exists</p>,}}/>)} -
Final functionality
Section titled โFinal functionalityโRefresh the page in your browser.
If all is working well, you should now have a fully functioning admin review page that allows you to publish or unpublish articles:

Summary
Section titled โSummaryโIn this step, you have:
- Created a new
/admin-reviewroute that displays a list of articles that are still indraftstatus. - Writte the server-side code to load articles and handle the publish/unpublish actions.
- Created a new child route
/admin-review/$articleIdthat displays the full article and allows you to publish or unpublish it.