How toCreate a CRUD with Remix

Let's say we have a model for articles in a blog, let's see how we could create a simple CRUD application with Remix and defining all the routes we need.

First we need the routes, so let's create these files:

app/routes/articles.tsx
app/routes/articles._index.tsx
app/routes/articles.$articleId.tsx
app/routes/articles_.$articleId_.edit.tsx
app/routes/articles_.new.tsx

The first one will have a shared layout, the second one will have the list, the third one the detail, the fourth the edit form and the last one the creation form.

The list and detail routes are nested inside the shared layout, but the edit and new routes will not be nested.

Layout Route

The layout route will be simple, we just need to render an <Outlet /> and any UI we want, we're not focusing on the UI here so I'm going to place some mock components.

app/routes/articles.tsx
import { Outlet } from "@remix-run/react"; export default function Component() { return ( <Layout> <Header /> <Outlet /> </Layout> ); }

The List Route

This is where we will show a list of every article, we will also paginate them and support searching and delete.

app/routes/articles._index.tsx
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; import { json } from "@remix-run/node": import { Form, useFetcher, useLoaderData } from "@remix-run/react"; import { z } from "zod" export async function loader({ request }: LoaderFunctionArgs) { let url = new URL(request.url); let page = z.coerce.number().parse(url.searchParams.get("page") ?? "1"); let query = z.string().nullable().parse(url.searchParams.get("query")); let {articles, last} = await Articles.findAll({ page, query }); return json({ articles, page, query, last }); } export async function action({ request }: ActionFunctionArgs) { let formData = await request.formData(); if (formData.get("intent") === "delete") { let articleId = z.coerce.number().parse(formData.get("articleId")); await Articles.delete(articleId); return redirect("/articles"); } throw new Error(`Invalid intent: ${formData.get("intent") ?? "Missing"}`); } export default function Component() { return ( <> <SearchForm /> <ArticleList /> <Pagination /> </> ) } function SearchForm() { let { query } = useLoaderData<typeof loader>(); return ( <Form role="search"> <label> <span>Query term</span> <input type="search" defaultValue={query} name="query" /> </label> <button>Search</Button> </Form> ) } function ArticleList() { let { articles } = useLoaderData<typeof loader>() return ( <ol> {articles.map(article => <ArticleItem key={article.id} article={article} />)} </ol> ) } function ArticleItem({ article }: { article: Article }) { let delete = useFetcher<typeof action>(); return ( <li> <h2>{article.title}</h2> <Link to={`/articles/${article.slug}`}>View</Link> <Link to={`/articles/${article.slug}/edit`}>View</Link> <delete.Form method="post"> <input type="hidden" name="intent" value="delete" /> <input type="hidden" name="articleId" value={article.id} /> <button>Delete</button> </delete.Form> </li> ) } function Paginate() { let { page, last } = useLoaderData<typeof loader>(); return ( <nav aria-label="Pagination"> {page > 1 && <Link to={`?page=${page - 1}`}>Previous</Link>} {page < last && <Link to={`?page=${page + 1}`}>Next</Link>} </nav> ) }

The Detail Route

This is where we will show the detail of an article, we will also link to the edit and allow deleting.

app/routes/articles.$articleId.tsx
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; import { json } from "@remix-run/node": import { Form, useFetcher, useLoaderData } from "@remix-run/react"; import { z } from "zod"; export async function loader({ params }: LoaderFunctionArgs) { let articleId = z.coerce.number().parse(params.articleId); let article = await Articles.find(articleId); return json({ article }); } export async function action({ request }: ActionFunctionArgs) { let formData = await request.formData(); if (formData.get("intent") === "delete") { let articleId = z.coerce.number().parse(formData.get("articleId")); await Articles.delete(articleId); return redirect("/articles"); } throw new Error(`Invalid intent: ${formData.get("intent") ?? "Missing"}`); } export default function Component() { let { article } = useLoaderData<typeof loader>(); return ( <> <h1>{article.title}</h1> <p>{article.body}</p> <Link to={`/articles/${article.slug}/edit`}>Edit</Link> <DeleteForm articleId={article.id} /> </> ) } function DeleteForm({ articleId }: { articleId: number }) { let delete = useFetcher<typeof action>(); return ( <Form method="post"> <input type="hidden" name="intent" value="delete" /> <input type="hidden" name="articleId" value={articleId} /> <button>Delete</button> </Form> ) }

The Edit Route

This is where we will show the edit form for an article, we will also allow deleting.

app/routes/articles_.$articleId_.edit.tsx
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"; import { json } from "@remix-run/node": import { Form, useLoaderData } from "@remix-run/react"; import { z } from "zod"; export async function loader({ params }: LoaderFunctionArgs) { let articleId = z.coerce.number().parse(params.articleId); let article = await Articles.find(articleId); return json({ article }); } export async function action({ request }: ActionFunctionArgs) { let formData = await request.formData(); if (formData.get("intent") === "update") { let articleId = z.coerce.number().parse(formData.get("articleId")); let title = z.string().parse(formData.get("title")); let body = z.string().parse(formData.get("body")); await Articles.update(articleId, { title, body }); return redirect(`/articles/${articleId}`); } throw new Error(`Invalid intent: ${formData.get("intent") ?? "Missing"}`); } export default function Component() { let { article } = useLoaderData<typeof loader>(); return ( <Form method="post"> <input type="hidden" name="intent" value="update" /> <input type="hidden" name="articleId" value={article.id} /> <label> <span>Title</span> <input type="text" name="title" defaultValue={article.title} /> </label> <label> <span>Body</span> <textarea name="body" defaultValue={article.body} /> </label> <button>Save</button> </Form> ) }

The New Route

This is where we will show the creation form for an article.

app/routes/articles_.new.tsx
import type { ActionFunctionArgs } from "@remix-run/node"; import { Form } from "@remix-run/react"; import { z } from "zod"; export async function action({ request }: ActionFunctionArgs) { let formData = await request.formData(); if (formData.get("intent") === "create") { let title = z.string().parse(formData.get("title")); let body = z.string().parse(formData.get("body")); await Articles.create({ title, body }); return redirect(`/articles`); } throw new Error(`Invalid intent: ${formData.get("intent") ?? "Missing"}`); } export default function Component() { return ( <Form method="post"> <input type="hidden" name="intent" value="create" /> <label> <span>Title</span> <input type="text" name="title" /> </label> <label> <span>Body</span> <textarea name="body" /> </label> <button>Save</button> </Form> ); }

And that's it, we have a simple CRUD application for our articles resource.