Skip to content

Creating articles

With our database schema and security in place, letโ€™s move on to creating articles.

If you arenโ€™t already, log in to the app as a user, or create a dummy user following the steps we covered previously.

Once logged in, access your userโ€™s site profile via the user actions dropdown in the top right corner of the screen:

User actions dropdown

This is the user profile page:

Profile page start

Once on your userโ€™s profile page, letโ€™s quickly add a button link to the โ€˜Articlesโ€™ page.

  1. Open the file at app/routes/users+/$username.tsx.

  2. Find the ProfileRoute component (around line 31), and add the following code to it, looking carefully at where it should be placed (just before the โ€˜My Notesโ€™ button).

    Feel free to expand the collapsed code sections to see the full component:

    app/routes/users+/$username.tsx
    export default function ProfileRoute() {
    41 collapsed lines
    const data = useLoaderData<typeof loader>()
    const user = data.user
    const userDisplayName = user.name ?? user.username
    const loggedInUser = useOptionalUser()
    const isLoggedInUser = data.user.id === loggedInUser?.id
    return (
    <div className="container mb-48 mt-36 flex flex-col items-center justify-center">
    <Spacer size="4xs" />
    <div className="container flex flex-col items-center rounded-3xl bg-muted p-12">
    <div className="relative w-52">
    <div className="absolute -top-40">
    <div className="relative">
    <img
    src={getUserImgSrc(data.user.image?.id)}
    alt={userDisplayName}
    className="h-52 w-52 rounded-full object-cover"
    />
    </div>
    </div>
    </div>
    <Spacer size="sm" />
    <div className="flex flex-col items-center">
    <div className="flex flex-wrap items-center justify-center gap-4">
    <h1 className="text-center text-h2">{userDisplayName}</h1>
    </div>
    <p className="mt-2 text-center text-muted-foreground">
    Joined {data.userJoinedDisplay}
    </p>
    {isLoggedInUser ? (
    <Form action="/logout" method="POST" className="mt-3">
    <Button type="submit" variant="link" size="pill">
    <Icon name="exit" className="scale-125 max-md:scale-150">
    Logout
    </Icon>
    </Button>
    </Form>
    ) : null}
    <div className="mt-10 flex gap-4">
    {isLoggedInUser ? (
    <>
    <Button asChild>
    <Link to="articles" prefetch="intent">
    My articles
    </Link>
    </Button>
    <Button asChild>
    <Link to="notes" prefetch="intent">
    My notes
    </Link>
    </Button>
    <Button asChild>
    <Link to="/settings/profile" prefetch="intent">
    Edit profile
    </Link>
    </Button>
    13 collapsed lines
    </>
    ) : (
    <Button asChild>
    <Link to="notes" prefetch="intent">
    {userDisplayName}'s notes
    </Link>
    </Button>
    )}
    </div>
    </div>
    </div>
    </div>
    )
    }
  3. Save your changes, and head back to the browser. You should see the new button link to the โ€˜Articlesโ€™ page:

    Profile with articles link

Click on this new button to navigate to the articles page.

Once here, click the + New Article button and fill in the form to create a new article:

My first article

Click the โ€˜Submitโ€™ button when finished, and your article will be created.

This is great, but we can make it even better by adding a category to our next article.

To do this, we will need to make some updates to the code.

If we are to edit an existing article, we will need to load the existing category value for the said article from the database.

This is defined in app/routes/users+/$username_+/articles.$articleId.tsx, where the loader function makes a call to the database to retrieve the article data.

  1. Open app/routes/users+/$username_+/articles.$articleId.tsx.

  2. At the moment, the article being loaded does not contain any category data. Letโ€™s update this.

    Adding the code below will start to make the code editor show red lines, indicating TypeScript errors.

    Donโ€™t worry about this for now. We will fix it later.

    app/routes/users+/$username_+/articles.$articleId.tsx
    30 collapsed lines
    import { getFormProps, useForm } from '@conform-to/react'
    import { parseWithZod } from '@conform-to/zod'
    import { invariantResponse } from '@epic-web/invariant'
    import {
    json,
    type LoaderFunctionArgs,
    type ActionFunctionArgs,
    } from '@remix-run/node'
    import {
    Form,
    Link,
    useActionData,
    useLoaderData,
    type MetaFunction,
    } from '@remix-run/react'
    import { formatDistanceToNow } from 'date-fns'
    import { z } from 'zod'
    import { GeneralErrorBoundary } from '~/components/ErrorBoundary.js'
    import { floatingToolbarClassName } from '~/components/floating-toolbar.tsx'
    import { ErrorList } from '~/components/forms.tsx'
    import { Button } from '~/components/ui/button.tsx'
    import { Icon } from '~/components/ui/icon.tsx'
    import { StatusButton } from '~/components/ui/status-button.tsx'
    import { requireUserId } from '~/utils/auth.server.ts'
    import { prisma } from '~/utils/db.server.ts'
    import { getArticleImgSrc, useIsPending } from '~/utils/misc.tsx'
    import { requireUserWithPermission } from '~/utils/permissions.server.ts'
    import { redirectWithToast } from '~/utils/toast.server.ts'
    import { userHasPermission, useOptionalUser } from '~/utils/user.ts'
    import { type loader as articlesLoader } from './articles.tsx'
    export async function loader({ params }: LoaderFunctionArgs) {
    const article = await prisma.article.findUnique({
    where: { id: params.articleId },
    select: {
    id: true,
    title: true,
    content: true,
    ownerId: true,
    updatedAt: true,
    category: {
    select: {
    id: true,
    name: true,
    },
    },
    images: {
    select: {
    id: true,
    altText: true,
    },
    },
    },
    })
    9 collapsed lines
    invariantResponse(article, 'Not found', { status: 404 })
    const date = new Date(article.updatedAt)
    const timeAgo = formatDistanceToNow(date)
    return json({
    article,
    timeAgo,
    })
    }

With this in place, we can now display the category name in the browser.

  1. Navigate down to the ArticleRoute component in the same file (around line 120), and add the JSX highlighted in green just below the h2 element:

    app/routes/users+/$username_+/articles.$articleId.tsx
    export default function ArticleRoute() {
    const data = useLoaderData<typeof loader>()
    const user = useOptionalUser()
    const isOwner = user?.id === data.article.ownerId
    const canDelete = userHasPermission(
    user,
    isOwner ? `delete:article:own` : `delete:article:any`,
    )
    const displayBar = canDelete || isOwner
    return (
    <div className="absolute inset-0 flex flex-col px-10">
    <h2 className="mb-2 pt-12 text-h2 lg:mb-6">{data.article.title}</h2>
    <div className="mb-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>
    </div>
    <div className={`${displayBar ? 'pb-24' : 'pb-12'} overflow-y-auto`}>
    // ... Code omitted for brevity ...
  2. Save your changes

  3. Check your browser, and you should now see the category name displayed on the article page:

    Article with category

Now that we can see the category name on the article page, letโ€™s add a category field to the article editor.

If a user clicks on an existing article they are the owner of (or have permissions to edit), they should be presented with an โ€˜Editโ€™ button.

Clicking the โ€˜Editโ€™ button opens the article editor form:

Display editor form

To edit the article, we need to find a different route file.

  1. Open the file at app/routes/users+/$username_+/articles.$articleId_.edit.tsx.

  2. Add the code shown below.

    This will produce red lines under some code, indicating TypeScript errors.

    Donโ€™t worry about this for now. We will fix it later.

    app/routes/users+/$username_+/articles.$articleId_.edit.tsx
    9 collapsed lines
    import { invariantResponse } from '@epic-web/invariant'
    import { json, type LoaderFunctionArgs } from '@remix-run/node'
    import { useLoaderData } from '@remix-run/react'
    import { GeneralErrorBoundary } from '#app/components/ErrorBoundary.js'
    import { requireUserId } from '#app/utils/auth.server.ts'
    import { prisma } from '#app/utils/db.server.ts'
    import { ArticleEditor } from './__article-editor.tsx'
    export { action } from './__article-editor.server.tsx'
    export async function loader({ params, request }: LoaderFunctionArgs) {
    const userId = await requireUserId(request)
    const categories = await prisma.articleCategory.findMany({
    select: {
    id: true,
    name: true,
    },
    })
    const article = await prisma.article.findFirst({
    select: {
    id: true,
    title: true,
    content: true,
    category: {
    select: {
    id: true,
    name: true,
    },
    },
    images: {
    select: {
    id: true,
    altText: true,
    },
    },
    },
    where: {
    id: params.articleId,
    ownerId: userId,
    },
    })
    invariantResponse(article, 'Not found', { status: 404 })
    return json({ article: article, categories })
    }
    export default function ArticleEdit() {
    const data = useLoaderData<typeof loader>()
    return <ArticleEditor categories={data.categories} article={data.article} />
    }
    export function ErrorBoundary() {
    return (
    <GeneralErrorBoundary
    statusHandlers={{
    404: ({ params }) => (
    <p>No article with the id "{params.articleId}" exists</p>
    ),
    }}
    />
    )
    }

We will also now need to load categories when creating a new article.

  1. Open the file app/routes/users+/$username_+/articles.new.tsx

  2. Add the code below:

    app/routes/users+/$username_+/articles.new.tsx
    import { json, type LoaderFunctionArgs } from '@remix-run/node'
    import { requireUserId } from '~/utils/auth.server.ts'
    import { ArticleEditor } from './__article-editor.tsx'
    import { prisma } from '~/utils/db.server.ts'
    import { useLoaderData } from '@remix-run/react'
    export { action } from './__article-editor.server.tsx'
    export async function loader({ request }: LoaderFunctionArgs) {
    await requireUserId(request)
    const categories = await prisma.articleCategory.findMany({
    select: {
    id: true,
    name: true,
    },
    })
    return json({})
    return json({ categories })
    }
    export default ArticleEditor
    export default function ArticleNew() {
    const data = useLoaderData<typeof loader>()
    return <ArticleEditor categories={data.categories} />
    }

With this in place, letโ€™s now update the editor form to include the new information being passed to it.

  1. Open the file at app/routes/users+/$username_+/__article-editor.tsx.

    This file handles the actual article editor form.

  2. Start by updating the imports at the top:

    app/routes/users+/$username_+/__article-editor.tsx
    9 collapsed lines
    import {
    FormProvider,
    getFieldsetProps,
    getFormProps,
    getInputProps,
    getTextareaProps,
    useForm,
    type FieldMetadata,
    } from '@conform-to/react'
    import { getZodConstraint, parseWithZod } from '@conform-to/zod'
    import {
    ArticleCategory,
    type Article,
    type ArticleImage,
    } from '@prisma/client'
    import { type SerializeFrom } from '@remix-run/node'
    11 collapsed lines
    import { Form, useActionData } from '@remix-run/react'
    import { useState } from 'react'
    import { z } from 'zod'
    import { GeneralErrorBoundary } from '~/components/ErrorBoundary.js'
    import { floatingToolbarClassName } from '~/components/floating-toolbar.tsx'
    import { ErrorList, Field, TextareaField } from '~/components/forms.tsx'
    import { Button } from '~/components/ui/button.tsx'
    import { Icon } from '~/components/ui/icon.tsx'
    import { Label } from '~/components/ui/label.tsx'
    import { StatusButton } from '~/components/ui/status-button.tsx'
    import { Textarea } from '~/components/ui/textarea.tsx'
    import { cn, getArticleImgSrc, useIsPending } from '~/utils/misc.tsx'
    import { type action } from './__article-editor.server'
    import SelectorGroup from '~/components/molecules/SelectorGroup.js'
  3. Just beneath the imports at the top of the screen is a list of const variables declaring the min and max lengths for the article title and content. Lets some new values here for our category field:

    app/routes/users+/$username_+/__article-editor.tsx
    26 collapsed lines
    import {
    FormProvider,
    getFieldsetProps,
    getFormProps,
    getInputProps,
    getTextareaProps,
    useForm,
    type FieldMetadata,
    } from '@conform-to/react'
    import { getZodConstraint, parseWithZod } from '@conform-to/zod'
    import {
    ArticleCategory,
    type Article,
    type ArticleImage,
    } from '@prisma/client'
    import { type SerializeFrom } from '@remix-run/node'
    import { Form, useActionData } from '@remix-run/react'
    import { useState } from 'react'
    import { z } from 'zod'
    import { GeneralErrorBoundary } from '~/components/ErrorBoundary.js'
    import { floatingToolbarClassName } from '~/components/floating-toolbar.tsx'
    import { ErrorList, Field, TextareaField } from '~/components/forms.tsx'
    import { Button } from '~/components/ui/button.tsx'
    import { Icon } from '~/components/ui/icon.tsx'
    import { Label } from '~/components/ui/label.tsx'
    import { StatusButton } from '~/components/ui/status-button.tsx'
    import { Textarea } from '~/components/ui/textarea.tsx'
    import { cn, getArticleImgSrc, useIsPending } from '~/utils/misc.tsx'
    import { type action } from './__article-editor.server'
    import SelectorGroup from '#app/components/molecules/SelectorGroup.js'
    const titleMinLength = 1
    const titleMaxLength = 100
    const contentMinLength = 1
    const contentMaxLength = 10000
    const categoryMinLength = 1
    const categoryMaxLength = 30
    // ... Code omitted for brevity ...
  4. Next, add these new values to the ArticleEditorSchema object, defined around line 54 to include the new category field:

    app/routes/users+/$username_+/__article-editor.tsx
    export const ArticleEditorSchema = z.object({
    id: z.string().optional(),
    title: z.string().min(titleMinLength).max(titleMaxLength),
    categoryId: z
    .string()
    .min(categoryMinLength)
    .max(categoryMaxLength)
    .optional(),
    content: z.string().min(contentMinLength).max(contentMaxLength),
    images: z.array(ImageFieldsetSchema).max(5).optional(),
    })

We are adding a new field to the schema called categoryId, which will hold the id of the selected category. This field is optional, as the user might decide not to place an article in any particular category.

categoryId: z
.string()
.min(categoryMinLength)
.max(categoryMaxLength)
.optional()

The min and max methods are used to define the minimum and maximum lengths of the categoryId field. In this case, the categoryId field must be between 1 and 30 characters long.

Next, we need to update the props coming into the ArticleEditor component (still inside app/routes/users+/$username_+/__article-editor.tsx).

We need to include a new category field to the article, and a new categories prop definition.

These lines of code need adding, starting from around line 64:

app/routes/users+/$username_+/__article-editor.tsx
export function ArticleEditor({
article,
categories,
}: {
article?: SerializeFrom<
Pick<Article, 'id' | 'title' | 'content'> & {
category: Pick<ArticleCategory, 'id' | 'name'> | null
} & {
images: Array<Pick<ArticleImage, 'id' | 'altText'>>
}
>
categories: Array<Pick<ArticleCategory, 'id' | 'name'>>
}) {
const actionData = useActionData<typeof action>()
const isPending = useIsPending()
const [form, fields] = useForm({
id: 'article-editor',
constraint: getZodConstraint(ArticleEditorSchema),
lastResult: actionData?.result,
onValidate({ formData }) {
return parseWithZod(formData, { schema: ArticleEditorSchema })
},
defaultValue: {
...article,
categoryId: article?.category?.id ?? '',
images: article?.images ?? [{}],
},
shouldRevalidate: 'onBlur',
})
// ... Code omitted for brevity ...
}

Here, we are telling TypeScript what the new article prop should look like, now that the category field is included. We are also defining the categories prop, which will be used to populate a new Categories field in the form.

Nothing has changed on the frontend yet, so letโ€™s fix that now.

Scroll down the ArticleEditor component further, and you will find the TextAreaField for the content field, around line 125.

Letโ€™s place our choices for the category field just below this:

app/routes/users+/$username_+/__article-editor.tsx
export function ArticleEditor({
11 collapsed lines
article,
categories,
}: {
article?: SerializeFrom<
Pick<Article, 'id' | 'title' | 'content'> & {
category: Pick<ArticleCategory, 'id' | 'name'> | null
} & {
images: Array<Pick<ArticleImage, 'id' | 'altText'>>
}
>
categories: Array<Pick<ArticleCategory, 'id' | 'name'>>
}) {
18 collapsed lines
const actionData = useActionData<typeof action>()
const isPending = useIsPending()
const [form, fields] = useForm({
id: 'article-editor',
constraint: getZodConstraint(ArticleEditorSchema),
lastResult: actionData?.result,
onValidate({ formData }) {
return parseWithZod(formData, { schema: ArticleEditorSchema })
},
defaultValue: {
...article,
category: article?.category?.name ?? '',
images: article?.images ?? [{}],
},
shouldRevalidate: 'onBlur',
})
const imageList = fields.images.getFieldList()
return (
<div className="absolute inset-0">
<FormProvider context={form.context}>
<Form
method="POST"
className="flex h-full flex-col gap-y-4 overflow-y-auto overflow-x-hidden px-10 pb-28 pt-12"
{...getFormProps(form)}
encType="multipart/form-data"
>
18 collapsed lines
{/*
This hidden submit button is here to ensure that when the user hits
"enter" on an input field, the primary form function is submitted
rather than the first button in the form (which is delete/add image).
*/}
<button type="submit" className="hidden" />
{article ? (
<input type="hidden" name="id" value={article.id} />
) : null}
<div className="flex flex-col gap-1">
<Field
labelProps={{ children: 'Title' }}
inputProps={{
autoFocus: true,
...getInputProps(fields.title, { type: 'text' }),
}}
errors={fields.title.errors}
/>
<TextareaField
labelProps={{ children: 'Content' }}
textareaProps={{
...getTextareaProps(fields.content),
}}
errors={fields.content.errors}
/>
<div className="pb-8">
<Label>Category</Label>
<SelectorGroup
options={categories.map(category => ({
value: category.id,
label: category.name,
}))}
/>
</div>
41 collapsed lines
<div>
<Label>Images</Label>
<ul className="flex flex-col gap-4">
{imageList.map((image, index) => {
console.log('image.key', image.key)
return (
<li
key={image.key}
className="relative border-b-2 border-muted-foreground"
>
<button
className="absolute right-0 top-0 text-foreground-destructive"
{...form.remove.getButtonProps({
name: fields.images.name,
index,
})}
>
<span aria-hidden>
<Icon name="cross-1" />
</span>{' '}
<span className="sr-only">
Remove image {index + 1}
</span>
</button>
<ImageChooser meta={image} />
</li>
)
})}
</ul>
</div>
<Button
className="mt-3"
{...form.insert.getButtonProps({ name: fields.images.name })}
>
<span aria-hidden>
<Icon name="plus">Image</Icon>
</span>{' '}
<span className="sr-only">Add image</span>
</Button>
</div>
<ErrorList id={form.errorId} errors={form.errors} />
</Form>
13 collapsed lines
<div className={floatingToolbarClassName}>
<Button variant="destructive" {...form.reset.getButtonProps()}>
Reset
</Button>
<StatusButton
form={form.id}
type="submit"
disabled={isPending}
status={isPending ? 'pending' : 'idle'}
>
Submit
</StatusButton>
</div>
</FormProvider>
</div>
)
}

Save your changes, and you should now see the categories available to choose from:

Article editor with categories

The SelectorGroup component appears to work fine on the frontend, but we need to modify its code to make sure it sends the correct information with the rest of the form data.

  1. Open the component file at app/components/molecules/SelectorGroup.tsx and take a look at the code.

  2. Add the code shown below:

    app/components/molecules/SelectorGroup.tsx
    import * as RadioGroup from '@radix-ui/react-radio-group'
    import { useState } from 'react'
    interface SelectorGroupProps {
    options: { value: string; label: string }[]
    name: string
    initialValue?: string
    }
    export default function SelectorGroup({
    options,
    name,
    initialValue,
    }: SelectorGroupProps) {
    let [selectedValue, setSelectedValue] = useState(initialValue ?? '')
    return (
    <RadioGroup.Root className="space-y-4">
    {options.map(option => (
    <RadioGroup.Item
    className={`flex w-full rounded-md border p-4 ${
    option.value === selectedValue
    ? 'border-sky-500 ring-1 ring-inset ring-sky-500'
    : 'border-gray-500'
    }`}
    key={option.value}
    type="button"
    onClick={() => setSelectedValue(option.value)}
    value={selectedValue}
    >
    <span className="font-semibold">{option.label}</span>
    </RadioGroup.Item>
    ))}
    <input type="hidden" name={name} value={selectedValue} />
    </RadioGroup.Root>
    )
    }

With these new changes to SelectorGroup, we now need to pass in the new props weโ€™ve defined.

  1. Go back to the code in app/routes/users+/$username_+/__article-editor.tsx

  2. Add the following props:

    app/routes/users+/$username_+/__article-editor.tsx
    <div className="pb-8">
    <Label>Category</Label>
    <SelectorGroup
    name="categoryId"
    initialValue={article?.category?.id ?? ''}
    options={categories.map(category => ({
    value: category.id,
    label: category.name,
    }))}
    />
    </div>
  3. Save your changes, and we can move on to the next step of handling the data once it is sent.

When data is submitted from a Remix Form component, it is sent to the nearest server-side action function which handles the form submission.

The logic for our form submission is handled in the app/routes/users+/$username_+/__article-editor.server.tsx file.

Open this now and take a look inside.

There is a lot going on!

Luckily, we only need to make three minor changes towards the end of the file.

These will ensure we capture the new categoryId field from the form data:

app/routes/users+/$username_+/__article-editor.server.tsx
29 collapsed lines
import { parseWithZod } from '@conform-to/zod'
import { createId as cuid } from '@paralleldrive/cuid2'
import {
unstable_createMemoryUploadHandler as createMemoryUploadHandler,
json,
unstable_parseMultipartFormData as parseMultipartFormData,
redirect,
type ActionFunctionArgs,
} from '@remix-run/node'
import { z } from 'zod'
import { requireUserId } from '#app/utils/auth.server.ts'
import { prisma } from '#app/utils/db.server.ts'
import {
MAX_UPLOAD_SIZE,
ArticleEditorSchema,
type ImageFieldset,
} from './__article-editor'
function imageHasFile(
image: ImageFieldset,
): image is ImageFieldset & { file: NonNullable<ImageFieldset['file']> } {
return Boolean(image.file?.size && image.file?.size > 0)
}
function imageHasId(
image: ImageFieldset,
): image is ImageFieldset & { id: NonNullable<ImageFieldset['id']> } {
return image.id != null
}
export async function action({ request }: ActionFunctionArgs) {
const userId = await requireUserId(request)
63 collapsed lines
const formData = await parseMultipartFormData(
request,
createMemoryUploadHandler({ maxPartSize: MAX_UPLOAD_SIZE }),
)
const submission = await parseWithZod(formData, {
schema: ArticleEditorSchema.superRefine(async (data, ctx) => {
if (!data.id) return
const article = await prisma.article.findUnique({
select: { id: true },
where: { id: data.id, ownerId: userId },
})
if (!article) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Article not found',
})
}
}).transform(async ({ images = [], ...data }) => {
return {
...data,
imageUpdates: await Promise.all(
images.filter(imageHasId).map(async i => {
if (imageHasFile(i)) {
return {
id: i.id,
altText: i.altText,
contentType: i.file.type,
blob: Buffer.from(await i.file.arrayBuffer()),
}
} else {
return {
id: i.id,
altText: i.altText,
}
}
}),
),
newImages: await Promise.all(
images
.filter(imageHasFile)
.filter(i => !i.id)
.map(async image => {
return {
altText: image.altText,
contentType: image.file.type,
blob: Buffer.from(await image.file.arrayBuffer()),
}
}),
),
}
}),
async: true,
})
if (submission.status !== 'success') {
return json(
{ result: submission.reply() },
{ status: submission.status === 'error' ? 400 : 200 },
)
}
const {
id: articleId,
title,
content,
categoryId,
imageUpdates = [],
newImages = [],
} = submission.value
const updatedArticle = await prisma.article.upsert({
select: { id: true, owner: { select: { username: true } } },
where: { id: articleId ?? '__new_article__' },
create: {
ownerId: userId,
title,
content,
categoryId,
images: { create: newImages },
},
update: {
title,
content,
categoryId,
images: {
deleteMany: { id: { notIn: imageUpdates.map(i => i.id) } },
updateMany: imageUpdates.map(updates => ({
where: { id: updates.id },
data: { ...updates, id: updates.blob ? cuid() : updates.id },
})),
create: newImages,
},
},
})
return redirect(
`/users/${updatedArticle.owner.username}/articles/${updatedArticle.id}`,
)
}

In this tutorial, we have:

  • Added a new category field to the article editor form
  • Loaded categories from the database
  • Displayed the category name on the article page
  • Updated the ArticleEditorSchema to include the new category field
  • Updated the ArticleEditor component to include the new category field
  • Updated the SelectorGroup component to include a hidden input field
  • Updated the ArticleEditor.server.tsx file to capture the new categoryId field
  • Generated new articles with a category field

In the next tutorial, we will add a content into the news page of the app, and display the articles in a grid layout.