Skip to content

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 live on the site.

For the time being, we will make this page available to everyone. We can grant access to admin users only in the next guide.

  1. The first thing we need is a new parent route to display a list of all articles.

    This is straightforward. Firstly, I need to create a new file inside app/routes called admin-review.tsx:

    Create admin review

  2. In app/routes/admin-review.tsx, I’ll set up a loader function that returns the id, title and isPublished values for each article:

    app/routes/admin-review.tsx
    import { json } from '@remix-run/node'
    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 json({ allArticles })
    }
  3. I’ll then use the same layout as the /users/$username/articles page to display the list of articles.

    The only thing I’ve added (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 { 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'
    export async function loader() {
    const allArticles = await prisma.article.findMany({
    select: { id: true, title: true, isPublished: true },
    })
    return json({ allArticles })
    }
    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() {
    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>
    )
    }

    Save your changes and check the /admin-review route in the browser.

    At first, of course, all the articles will show a red β€˜D’ to signify β€˜Draft’:

    Initial admin review

    As we update the articles, the status will change to a green β€˜P’ for those that we mark as β€˜Published’.

  4. I’ll also create a new child index route file at app/routes/admin-review._index.tsx:

    Create admin review index

    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-review route should look something like this:

    Article review styled

    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.

  5. If I add a .server suffix to a route file name, this will make it a server route.

    Create a new file at app/routes/__admin-review.$articleId.server.tsx:

    Create admin review server file

    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 {
    type LoaderFunctionArgs,
    type ActionFunctionArgs,
    } from '@remix-run/node'
    import { json } from '@remix-run/react'
    import { formatDistanceToNow } from 'date-fns'
    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 lines
    where: { 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: {
    id: true,
    altText: true,
    },
    },
    },
    })
    10 collapsed lines
    invariantResponse(article, 'Not found', { status: 404 })
    const updatedAtDate = new Date(article.updatedAt)
    const publishedAtDate = article.publishedAt
    ? new Date(article.publishedAt)
    : null
    const timeAgoUpdated = formatDistanceToNow(updatedAtDate)
    const timeAgoPublished = publishedAtDate
    ? formatDistanceToNow(publishedAtDate)
    : null
    return json({
    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 lines
    schema: PublishArticleSchema.superRefine(async (data, ctx) => {
    if (!data.publish && !data.unpublish) return
    const 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 json(
    { result: submission.reply() },
    { status: submission.status === 'error' ? 400 : 200 },
    )
    }
    const { publish, unpublish } = submission.value
    const 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'}`,
    })
    }
  6. I’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:

    Create admin-review-article-id

    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.tsx route file:

    app/routes/admin-review/$articleId.tsx
    11 collapsed lines
    import { getFormProps, useForm } from '@conform-to/react'
    import { Form, useActionData, useLoaderData } from '@remix-run/react'
    import { GeneralErrorBoundary } from '~/components/ErrorBoundary.js'
    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'
    import type { action, loader } from './__admin-review.$articleId.server'
    export { action, loader } from './__admin-review.$articleId.server'
    export default function AdminReviewRoute() {
    const data = useLoaderData<typeof loader>()
    return (
    <div className="absolute inset-0 flex flex-col px-10">
    55 collapsed lines
    <h2 className="mb-2 pt-12 text-h2 lg:mb-6">{data.article.title}</h2>
    <div className="mb-4 flex justify-between gap-4">
    <p className="w-fit rounded-lg bg-card px-4 py-2 text-sm text-card-foreground">
    {data.article.category?.name ?? 'General News'}
    </p>
    <p
    className={`w-fit rounded-lg ${data.article.isPublished ? 'bg-green-800' : 'bg-destructive'} px-4 py-2 text-xs font-bold text-card-foreground`}
    >
    {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.id}>
    <a href={getArticleImgSrc(image.id)}>
    <img
    src={getArticleImgSrc(image.id)}
    alt={image.altText ?? ''}
    className="h-32 w-32 rounded-lg object-cover"
    />
    </a>
    </li>
    ))}
    </ul>
    <p className="whitespace-break-spaces text-sm md:text-lg">
    {data.article.content}
    </p>
    </div>
    <div className={floatingToolbarClassName}>
    <span className="text-sm text-foreground/90 max-[524px]:hidden">
    <Icon name="clock" className="scale-125">
    {data.timeAgoUpdated} ago
    </Icon>
    </span>
    <span
    className={`text-sm ${data.timeAgoPublished ? 'text-foreground/90' : 'text-red/90'} max-[524px]:hidden`}
    >
    <Icon
    name={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">
    <ArticleStatusForm
    id={data.article.id}
    isPublished={data.article.isPublished}
    />
    </div>
    </div>
    </div>
    )
    }
    interface PublishArticleProps {
    id: string
    isPublished: 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
    <input
    type="hidden"
    name={isPublished ? 'unpublish' : 'publish'}
    value={id}
    />
    <StatusButton
    type="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"
    >
    <Icon
    name={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 (
    <GeneralErrorBoundary
    statusHandlers={{
    403: () => <p>You are not allowed to do that</p>,
    404: ({ params }) => <p>No article with the id exists</p>,
    }}
    />
    )
    }
  7. 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:

    Publish unpublish article

In this step, you have:

  • Created a new /admin-review route that displays a list of articles that are still in draft status.
  • Writte the server-side code to load articles and handle the publish/unpublish actions.
  • Created a new child route /admin-review/$articleId that displays the full article and allows you to publish or unpublish it.