Fullstack serverless web development with Remix and Architect (Part 5, new feature development)

Tuomas Koivistoinen

In the last part of our series on Remix and Architect, we refactored our forms to be more succinct. In this part we will start building new features, making full use of the components we made in the previous parts.

New feature development

Our goal is that our sample application has “projects” in a similar way it has notes. We will start off by creating a DynamoDB table for projects, then we will add navigation to the projects page, create a page that lists them, a form that creates them, and a page that details them and where you can delete them.

Projects DynamoDB table

To add a new DynamoDB table, we need to open the app.arc file from the root, add the following lines and save the file:

project
  pk *String  # userId
  sk **String # projectId

The table is automatically available for local development and will be created for staging and production environments in CI/CD.

Navigation to the projects page

To add similar navigation to the projects page, that we have for the notes page we need to add a link to the projects page in app/routes/app.tsx.

import Navbar from "~/components/Navbar";

export default function AppPage() {
  return (
    <Navbar
      links={[
        { to: "/app/notes", label: "Notes" },
        { to: "/app/projects", label: "Projects" },
      ]}
    />
  );
}

List projects

To have a similar listing of projects that we have for notes, we can start off by copying app/routes/app/notes.tsx into app/routes/app/projects.tsx. Now we can navigate to the projects page, but we are only seeing the notes. To actually list the projects, we need to first create a function for getting the projects from the database. Let’s first create a file app/models/project.server.ts, add some types and a function that gets the projects from the database.

import arc from "@architect/functions";

import type cuid from "cuid";
import type { User } from "./user.server";

export type Project = {
  id: ReturnType<typeof cuid>;
  userId: User["id"];
  title: string;
  description: string;
};

type ProjectItem = {
  pk: User["id"];
  sk: `project#${Project["id"]}`;
};

const skToId = (sk: ProjectItem["sk"]): Project["id"] =>
  sk.replace(/^project#/, "");

export async function getProjectListItems({
  userId,
}: Pick<Project, "userId">): Promise<Array<Pick<Project, "id" | "title">>> {
  const db = await arc.tables();

  const result = await db.project.query({
    KeyConditionExpression: "pk = :pk",
    ExpressionAttributeValues: { ":pk": userId },
  });

  return result.Items.map((n: any) => ({
    title: n.title,
    id: skToId(n.sk),
  }));
}

Now we can use this function in the loader and then fix types and the React component.

import type { LoaderFunction } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

import { requireUserId } from "~/session.server";
import MainArea from "~/components/MainArea";
import { getProjectListItems } from "~/models/project.server";

type LoaderData = {
  items: Awaited<ReturnType<typeof getProjectListItems>>;
};

export const loader: LoaderFunction = async ({ request }) => {
  const userId = await requireUserId(request);
  const items = await getProjectListItems({ userId });
  return json<LoaderData>({ items });
};

export default function ProjectsPage() {
  const data = useLoaderData() as LoaderData;

  return (
    <MainArea
      items={data.items}
      labelAdd={"+ Create Project"}
      labelEmptyState={"No projects yet"}
    />
  );
}

Now our list should work, but we now need to create projects.

Create projects

We again need a function to communicate with the database, but this time in the route we don’t need a loader – we need an action. Let’s start with the function that creates a project. Inside app/models/project.server.ts let’s change the cuid type import into a regular import and add the functions:

const idToSk = (id: Project["id"]): ProjectItem["sk"] => `project#${id}`;

export async function createProject({
  description,
  title,
  userId,
}: Pick<Project, "description" | "title" | "userId">): Promise<Project> {
  const db = await arc.tables();

  const result = await db.project.put({
    pk: userId,
    sk: idToSk(cuid()),
    title,
    description,
  });
  return {
    id: skToId(result.sk),
    userId: result.pk,
    title: result.title,
    description: result.description,
  };
}

Now let’s create a route app/routes/app/projects/new.tsx and use that function in the action. 

import type { ActionFunction } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { withZod } from "@remix-validated-form/with-zod";
import { validationError } from "remix-validated-form";
import { z } from "zod";
import BasicForm from "~/components/BasicForm";
import { TextInput } from "~/components/TextInput";
import { createProject } from "~/models/project.server";
import { requireUserId } from "~/session.server";

export const validator = withZod(
  z.object({
    title: z.string().min(1, "Title is required"),
    description: z.string().min(1, "Description is required"),
  })
);

export const action: ActionFunction = async ({ request }) => {
  const userId = await requireUserId(request);

  const result = await validator.validate(await request.formData());
  if (result.error) return validationError(result.error);

  const { title, description } = result.data;
  const project = await createProject({ title, description, userId });

  return redirect(`app/projects/${project.id}`);
};

export default function NewProjectPage() {
  return (
    <BasicForm validator={validator}>
      <TextInput name="title" label="Title" />
      <TextInput name="description" label="Description" />
    </BasicForm>
  );
}

Now we can create a project, get redirected to the project details page, but it doesn’t yet exist so we are greeted with a 404. Let’s fix it by adding a project details page

Project details

In this page we need to get data about the project, display it and have a button to delete it. Again we need functions to communicate with the database, but in addition, we need both a loader for reading and an action for deleting in the route file. Let’s add the database functions to the app/models/project.server.ts file.

export async function getProject({
  id,
  userId,
}: Pick<Project, "id" | "userId">): Promise<Project | null> {
  const db = await arc.tables();

  const result = await db.project.get({ pk: userId, sk: idToSk(id) });

  if (result) {
    return {
      userId: result.pk,
      id: result.sk,
      title: result.title,
      description: result.description,
    };
  }
  return null;
}

export async function deleteProject({ id, userId }: Pick<Project, "id" | "userId">) {
  const db = await arc.tables();
  return db.project.delete({ pk: userId, sk: idToSk(id) });
}

Now we can create the app/projects/$projectId.tsx route, load data with getProject in the loader, delete the project with deleteProject in the action, add a CatchBoundary for http errors and an ErrorBoundary for JavaScript errors.

import type { ActionFunction, LoaderFunction } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, useCatch, useLoaderData } from "@remix-run/react";
import invariant from "tiny-invariant";
import { deleteProject, getProject } from "~/models/project.server";
import { requireUserId } from "~/session.server";

import type { Project } from "~/models/project.server";

type LoaderData = {
  item: Project;
};

export const loader: LoaderFunction = async ({ request, params }) => {
  const userId = await requireUserId(request);
  invariant(params.projectId, "invalid path parameter");

  const item = await getProject({ userId, id: params.projectId });
  if (!item) {
    throw new Response("Not Found", { status: 404 });
  }
  return json<LoaderData>({ item });
};

export const action: ActionFunction = async ({ request, params }) => {
  const userId = await requireUserId(request);
  invariant(params.projectId, "invalid path parameter");

  await deleteProject({ userId, id: params.projectId });

  return redirect("/app/projects");
};

export default function ProjectDetailsPage() {
  const data = useLoaderData() as LoaderData;

  return (
    <div>
      <h3 className="text-2xl font-bold">{data.item.title}</h3>
      <p className="py-6">{data.item.description}</p>
      <hr className="my-4" />
      <Form method="post">
        <button
          type="submit"
          className="rounded bg-blue-500  py-2 px-4 text-white hover:bg-blue-600 focus:bg-blue-400"
        >
          Delete
        </button>
      </Form>
    </div>
  );
}

export function ErrorBoundary({ error }: { error: Error }) {
  console.error(error);

  return <div>An unexpected error occurred: {error.message}</div>;
}

export function CatchBoundary() {
  const caught = useCatch();

  if (caught.status === 404) {
    return <div>Project not found</div>;
  }

  throw new Error(`Unexpected caught response with status: ${caught.status}`);
}

This route is also begging for a refactor, but since it’s less than 70 lines I can restrain myself. I hope you can restrain yourself too so we can move on.

No project selected page

If we navigate to app/projects, we are greeted with an empty main area because the outlet returns null. We can fix it by adding an index route. Let’s create the app/routes/app/projects/index.tsx file and just give some directions to users.

import { Link } from "@remix-run/react";

export default function ProjectIndexPage() {
  return (
    <p>
      No project selected. Select a project on the left, or{" "}
      <Link to="new" className="text-blue-500 underline">
        create a new project.
      </Link>
    </p>
  );
}

Coming Up in Part 6

In this part, we got projects to feature parity with notes. When you are ready, please join me in the next part where we will set up and go live.

Get in Touch.

Let’s discuss how we can help with your cloud journey. Our experts are standing by to talk about your migration, modernisation, development and skills challenges.