DEV Community

Cover image for Remix Tutorial: Building A Simple Contact App With Strapi as Backend
Syakir for Strapi

Posted on • Originally published at strapi.io

Remix Tutorial: Building A Simple Contact App With Strapi as Backend

Introduction

In this Remix tutorial, we’ll walk through how to build a simple contact app using Remix for the frontend and Strapi headless CMS as the backend. You’ll learn how to set up both tools, connect them, implement CRUD operations and create a working application step by step.

Prerequisites

Before starting this Remix tutorial, make sure you have the following:

  • Node.js and npm installed on your machine (v18 or later recommended).
  • A basic understanding of HTML, CSS and JavaScript.
  • Git installed for version control.
  • A code editor, like VS Code.

What you'll learn

In this tutorial, you will learn:

  • How to set up a Remix project and understand its core features.
  • Setting up Strapi as a backend and integrating it with Remix.
  • Implementing CRUD functionalities (Create, Read, Update, Delete) for a simple contact management app.
  • Using Zod for form validation and useNavigate for navigation actions.
  • Adding advanced features like search, sorting, pagination, and favorite functionality.
  • Handling errors, creating a 404 fallback, and improving user experience with loading states and debouncing.
  • Exploring additional improvements such as highlighting selected contacts and using useFetcher for better interactions.

What we're building

In this tutorial, we’ll build a simple contact management app with features:

  • Contact List and Detail View: Display all contacts and view details for each contact.
  • CRUD Operations: Add, edit, update, and delete contact information seamlessly.
  • Favorite Marking: Mark contacts as favorites for easier organization.
  • Search Functionality: Implement real-time search
  • Error Handling and 404 Pages: Handle missing data and server errors gracefully with custom fallback pages.
  • Enhanced User Experience: Add features like loading states and highlighting selected contacts.

Getting Started with Remix

Remix is a React-based framework for building fast, dynamic web apps with seamless server and client rendering. It simplifies routing, data loading, and error handling while focusing on performance, web standards, and accessibility

Setting up Remix project

To make it simple, we will refer to the official remix documentation to setup remix project.

We will also use the official remix tutorial project. Later, we will integrate this tutorial project with Strapi as backend.

First, let's create a project folder remix-strapi, and open it in the Terminal. This folder will contains 2 forlders for remix (frontend) and strapi (backend).

Before setting up remix and strapi backend, we will initialize git repository in this folder:

git init
Enter fullscreen mode Exit fullscreen mode

Then, set up a remix project by running this command:

npx create-remix@latest --template remix-run/remix/templates/remix-tutorial
Enter fullscreen mode Exit fullscreen mode

It will prompt you with some questions in your terminal

Need to install the following packages:
create-remix@2.15.1
Ok to proceed? (y) y


 remix   v2.15.1 💿 Let's build a better website...

   dir   Where should we create your new project?
         ./remix

      ◼  Template: Using remix-run/remix/templates/remix-tutorial...
      ✔  Template copied

   git   Initialize a new git repository?
         No

  deps   Install dependencies with npm?
         Yes

      ✔  Dependencies installed

  done   That's it!

         Enter your project directory using cd ./remix
         Check out README.md for development and deploy instructions.

         Join the community at https://rmx.as/discord

Enter fullscreen mode Exit fullscreen mode

We named the project folder remix. Select No for Git repository initialization, as we have already initialized Git in the parent folder, remix-strapi.

Now let's open remix-strapi in VSCode. You'll find a remix folder inside. Under the remix folder, there will be an app folder where application code is located.

Inside remix folder.jpg

  • root.tsx is the entry point of the application. We will refer to this as the "Root route"
  • data.ts store the functions related to data operations
  • app.css is the main CSS file for styling the app.

Now, let's run our Remix app. Right-click on the remix folder and select Open in Integrated Terminal. This will open the terminal in your VSCode.

Run the command below:

npm run dev
Enter fullscreen mode Exit fullscreen mode

The Remix app will start on localhost:5173, but you will see an unstyled page.

Remix unstyled page.jpg

This is because we haven't imported the app.css file in root.tsx.

Importing CSS in Root route

Let's import the app.css in root.tsx by adding the code on lines 9–13 as shown below.

import {
  Form,
  Links,
  Meta,
  Scripts,
  ScrollRestoration,
} from "@remix-run/react";

import type { LinksFunction } from "@remix-run/node";
import appStyleHref from './app.css?url'
export const links: LinksFunction = () => [
  { rel:"stylesheet", href: appStyleHref }
]

export default function App() {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
      </head>
      <body>
        <div id="sidebar">
        ...
        </div>
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

Refresh the app, and it will render the styles.

Styled page.jpg

Click on a contact in the list, and you will see a 404 Not Found page. This happens because we haven't set up the routes for it.

Creating Routes in Remix

Now, let's create a new folder named routes to store all the page routes we plan to add.

We'll start by creating a /contacts route.

Contact route.jpg

Then, we have to add the <Outlet /> component in the root.tsx to render the route component.

import {
  Form,
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
} from "@remix-run/react";

import type { LinksFunction } from "@remix-run/node";
import appStyleHref from './app.css'
export const links: LinksFunction = () => [
  { rel:"stylesheet", href: appStyleHref }
]

export default function App() {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
      </head>
      <body>
        <div id="sidebar">
        ...
        </div>

        <div id="detail">
          <Outlet />
        </div>

        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

Enter fullscreen mode Exit fullscreen mode

First, we need to import Outlet from @remix-run/react, as shown on line 5 in the code above. Then, use the <Outlet /> component, as shown on lines 30–32 in the code above.

Static route.jpg

You see that it now renders the ContactsRoute component.

However, this is just a static route. Static routes are used to display static content in your web application.

If you click a contact in the sidebar, you will still see a 404 Not Found error page. This is because it's expecting a dynamic route.

To create a dynamic route, simply rename the file contacts.tsx to contacts.$contactId.tsx.

Now, click a contact in the sidebar again. This time, it won't render the 404 Not Found error page.

The number 1 in /contacts/1 will be passed as the $contactId parameter to the ContactsRoute component. We'll use it to fetch contact data in the next step.

Setting up Contact Detail Page

Before we work on the data fetching, let's now create a proper component for contact detail page first.

Change all codes in the contacts.$contactId.tsx component with this code.

import { Form, Link } from "@remix-run/react";
import type { FunctionComponent } from "react";

import type { ContactRecord } from "../data";

export default function Contact() {
  const contact = {
    first: "Your",
    last: "Name",
    avatar: "https://placecats.com/200/200",
    twitter: "your_handle",
    notes: "Some notes",
    favorite: true,
  };

  return (
    <div id="contact">
      <div>
        <img
          alt={`${contact.first} ${contact.last} avatar`}
          key={contact.avatar}
          src={contact.avatar}
        />
      </div>

      <div>
        <h1>
          {contact.first || contact.last ? (
            <>
              {contact.first} {contact.last}
            </>
          ) : (
            <i>No Name</i>
          )}{" "}
          <Favorite contact={contact} />
        </h1>

        {contact.twitter ? (
          <p>
            <a
              href={`https://twitter.com/${contact.twitter}`}
            >
              {contact.twitter}
            </a>
          </p>
        ) : null}

        {contact.notes ? <p>{contact.notes}</p> : null}

        <div>
          <Link to={`/contacts/${contact.documentId}/edit`} className="buttonLink">Edit</Link>

          <Form
            action="delete"
            method="post"
            onSubmit={(event) => {
              const response = confirm(
                "Please confirm you want to delete this record."
              );
              if (!response) {
                event.preventDefault();
              }
            }}
          >
            <button type="submit">Delete</button>
          </Form>
        </div>
      </div>
    </div>
  );
}

const Favorite: FunctionComponent<{
  contact: Pick<ContactRecord, "favorite">;
}> = ({ contact }) => {
  const favorite = contact.favorite;

  return (
    <Form method="post">
      <button
        aria-label={
          favorite
            ? "Remove from favorites"
            : "Add to favorites"
        }
        name="favorite"
        value={favorite ? "false" : "true"}
      >
        {favorite ? "" : ""}
      </button>
    </Form>
  );
};

Enter fullscreen mode Exit fullscreen mode

In the code above,

  • We use a dummy contact data, stored it in contact variable.
  • In the component markup, we render contact fields in UI.
  • We added a Favorite component, Edit and Delete button. For now, they dont have any functionality yet. We will implement theme later in this tutorial.

The code will render this page. It's currently displaying a dummy contact, but soon we'll make it load the actual contact based on the ID in the URL.

Contact detail.jpg

Using Loader Function to Load Data

Before using real data, we'll use the dummy contact data stored in data.ts.

Let's go back to root.tsx. We'll load the contact list in the sidebar from the dummy data.

First, import the getContacts function from ./data.ts and use it in a loader function.

Then, loop through the fetched contacts data inside the <nav> element in the sidebar.

Here is the updated root.tsx.

import {
  Form,
  Link,
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
  useLoaderData,
} from "@remix-run/react";
import { type LinksFunction } from "@remix-run/node";
import appStyleHref from './app.css'
import { getContacts } from "./data";
export const links: LinksFunction = () => [
  { rel:"stylesheet", href: appStyleHref }
]

export const loader = async () => {
  const contacts = await getContacts();
  return { contacts }
};

export default function App() {
  const { contacts } = useLoaderData<typeof loader>();
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
      </head>
      <body>
        <div id="sidebar">
          <h1>Remix Contacts</h1>
          <div>
            <Form id="search-form" role="search">
              <input
                id="q"
                aria-label="Search contacts"
                placeholder="Search"
                type="search"
                name="q"
              />
              <div id="search-spinner" aria-hidden hidden={true} />
            </Form>
            <Form method="post">
              <button type="submit">New</button>
            </Form>
          </div>
          <nav>
            {contacts.length ? (
              <ul>
                {contacts.map((contact) => (
                  <li key={contact.id}>
                    <Link to={`contacts/${contact.id}`}>
                      {contact.first || contact.last ? (
                        <>
                          {contact.first} {contact.last}
                        </>
                      ) : (
                        <i>No Name</i>
                      )}{" "}
                      {contact.favorite ? (
                        <span></span>
                      ) : null}
                    </Link>
                  </li>
                ))}
              </ul>
            ) : (
              <p>
                <i>No contacts</i>
              </p>
            )}
          </nav>
        </div>

        <div id="detail">
          <Outlet />
        </div>

        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

Enter fullscreen mode Exit fullscreen mode

The loader function should be explicitly exported as loader to work.

It returns the contacts from the dummy data, allowing us to fetch it using useLoaderData in the main component.

Refresh the page, it will render the contact list in the sidebar.

Contact list in sidebar.jpg

Loading Single Contact based on Id param

Now, we're going to fetch contact data based on the id param in the url.

We just need to add another loader function, but this time with contactId parameter

import { Form, useLoaderData } from "@remix-run/react";
import type { FunctionComponent } from "react";

import { getContact, type ContactRecord } from "../data";
import type { LoaderFunctionArgs } from "@remix-run/node";
import invariant from "tiny-invariant";

export const loader = async ({
  params,
}: LoaderFunctionArgs) => {
  invariant(params.contactId, "Missing contactId param");
  const contact = await getContact(params.contactId);
  if (!contact) {
    throw new Response("Not Found", { status: 404 });
  }
  return { contact };
};

export default function Contact() {
  const { contact } = useLoaderData<typeof loader>();
  return (
    <div id="contact">
        ...
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

In the code above:

  • The loader function has a { params } argument that contains the contactId parameter from the URL.
  • invariant is a utility function used to verify whether the contact ID parameter exists in the URL.
  • The getContact function from /data.ts is used to fetch individual contact data based on the contactId parameter. If the contact is not found, it returns a Not Found error.
  • In the main component, we replace the dummy contact with useLoaderData. There are no changes to the component markup.

Setting Up Strapi as Backend

Strapi is an open-source headless CMS designed to simplify content management and API creation for developers. It offers an intuitive admin panel, allowing users to create custom content types and manage data seamlessly while supporting RESTful and GraphQL APIs for flexible integration with any frontend framework.

We will use Strapi backend to store our contacts data and fetch it in remix app.

Add Strapi to the Project

First, let's set up the Strapi project.

Navigate to the root project folder, remix-strapi, in your terminal. If your terminal is currently in the remix folder, you can switch to the remix-strapi folder by running cd ../.

Once in the remix-strapi folder, run:

npx create-strapi@latest my-strapi-project

It will prompt you with some questions again

Need to install the following packages:
create-strapi@5.5.1
Ok to proceed? (y) y


 Strapi   v5.5.1 🚀 Let's create your new project


We can't find any auth credentials in your Strapi config.

Create a free account on Strapi Cloud and benefit from:

- ✦ Blazing-fast ✦ deployment for your projects
- ✦ Exclusive ✦ access to resources to make your project successful
- An ✦ Awesome ✦ community and full enjoyment of Strapi's ecosystem

Start your 14-day free trial now!


? Please log in or sign up. Skip
? Do you want to use the default database (sqlite) ? Yes
? Start with an example structure & data? No
? Start with Typescript? Yes
? Install dependencies with npm? Yes
? Initialize a git repository? No

 Strapi   Creating a new application at /Volumes/Work/Labs/remix-strapi/my-strapi-project

   deps   Installing dependencies with npm
npm warn deprecated inflight@1.0.6: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
npm warn deprecated rimraf@3.0.2: Rimraf versions prior to v4 are no longer supported
npm warn deprecated rimraf@3.0.2: Rimraf versions prior to v4 are no longer supported
npm warn deprecated rimraf@3.0.2: Rimraf versions prior to v4 are no longer supported
npm warn deprecated glob@7.2.3: Glob versions prior to v9 are no longer supported
npm warn deprecated glob@7.2.3: Glob versions prior to v9 are no longer supported
npm warn deprecated glob@7.2.3: Glob versions prior to v9 are no longer supported
npm warn deprecated glob@7.2.3: Glob versions prior to v9 are no longer supported
npm warn deprecated glob@7.2.3: Glob versions prior to v9 are no longer supported
npm warn deprecated glob@7.2.3: Glob versions prior to v9 are no longer supported
npm warn deprecated glob@7.2.3: Glob versions prior to v9 are no longer supported
npm warn deprecated mailcomposer@3.12.0: This project is unmaintained
npm warn deprecated buildmail@3.10.0: This project is unmaintained
npm warn deprecated boolean@3.2.0: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.

added 1372 packages, and audited 1373 packages in 3m

200 packages are looking for funding
  run `npm fund` for details

4 low severity vulnerabilities

Some issues need review, and may require choosing
a different dependency.

Run `npm audit` for details.

       ✓  Dependencies installed

 Strapi   Your application was created!
          Available commands in your project:

          Start Strapi in watch mode. (Changes in Strapi project files will trigger a server restart)
          npm run develop

          Start Strapi without watch mode.
          npm run start

          Build Strapi admin panel.
          npm run build

          Deploy Strapi project.
          npm run deploy

          Display all available commands.
          npm run strapi

          To get started run

          cd /Volumes/Work/Labs/remix-strapi/my-strapi-project
          npm run develop
Enter fullscreen mode Exit fullscreen mode

In the terminal prompt above:

  • I skipped the Strapi Cloud free trial registration since we will use the project locally for demo purposes only.
  • Chose SQLite for the database.
  • Started with no structure or data examples.
  • Selected TypeScript.
  • Used npm as the dependency manager.
  • Skipped Git initialization since we already initialized Git in the parent folder.

Now, let's run our Strapi project. In the terminal, navigate to the Strapi project folder my-strapi-project using cd my-strapi-project.

Then, run npm run develop.

Strapi will open at http://localhost:1337/. Since this is a new installation, you'll need to register an admin user before logging into the Strapi dashboard.

Strapi registration.jpg

Once registered, you will be logged into the Strapi dashboard.

Strapi 5 Dashboard.jpg

Take some time to explore Strapi's features and familiarize yourself with the dashboard.

Some core features you should explore:

  • Content-Type Builder: Use this to build content types based on your needs. There are three types of content you can create:
    • Collection Type
    • Single Type
    • Component

By default, Strapi provides an existing user collection.

  • Content Manager: Manage your content here—create, update, or delete entries after setting up your collection or other content types.
  • Media Library: Store and manage images, videos, and other media that will be used in your content.

Create Contact Collection on Strapi

Before storing contacts in Strapi, we need to create a Contact collection.

Navigate to the Content-Type Builder and create a new Contact collection.

You will be prompted to select fields for the newly created Contact collection.

We will create the Contact fields based on the ContactMutation type, which you can find in ./data.ts in your Remix project, excluding the id column.

type ContactMutation = {
  id?: string;
  first?: string;
  last?: string;
  avatar?: string;
  twitter?: string;
  notes?: string;
  favorite?: boolean;
};
Enter fullscreen mode Exit fullscreen mode

Contact Collection.jpg

All fields we created have the Text (Short text) type, except for notes, which is Text (Long text), and favorite, which is Boolean.

Save the collection, and now we can start adding contact data to Strapi.

Navigate to the Content Manager menu. Select the Contact collection and choose Create new entry.

You can use the dummy contacts from ./data.ts in your remix project and save them to the Contact collection.

Implementing CRUD Functionalities for Contact

Now, we are going to use the Contact collection as the data source for our Remix app.

Before that, we need to expose the Contact collection to the public API.

In the Strapi dashboard, go to Settings > scroll to Users & Permissions Plugin > select Roles > Public.

On the Permissions section, click on Contact and Select all permissions.

Contact collection permission.jpg

This will make your Contact collection accessible for Create, Read, Update, and Delete (CRUD) operations via API endpoints .

Go to http://localhost:1337/api/contacts. It will return your contact list in JSON format.

{
    "data": [
        {
            "id": 2,
            "documentId": "dva2ab16hv3bzq9s5onbxgvb",
            "first": "Shruti",
            "last": "Kapoor",
            "avatar": "https://sessionize.com/image/124e-400o400o2-wHVdAuNaxi8KJrgtN3ZKci.jpg",
            "twitter": "@shrutikapoor08",
            "notes": null,
            "favorite": null,
            "createdAt": "2024-12-12T08:21:21.633Z",
            "updatedAt": "2024-12-12T08:21:21.633Z",
            "publishedAt": "2024-12-12T08:21:21.657Z"
        },
        {
            "id": 4,
            "documentId": "y1s5z2oqmmppu1d9n7wmelc9",
            "first": "Ryan",
            "last": "Florence",
            "avatar": "https://sessionize.com/image/9273-400o400o2-3tyrUE3HjsCHJLU5aUJCja.jpg",
            "twitter": null,
            "notes": null,
            "favorite": true,
            "createdAt": "2024-12-12T08:21:41.582Z",
            "updatedAt": "2024-12-12T08:21:41.582Z",
            "publishedAt": "2024-12-12T08:21:41.587Z"
        },
        {
            "id": 6,
            "documentId": "fnpqe7u1pm70bkcx6yjzahq7",
            "first": "Oscar",
            "last": "Newman",
            "avatar": "https://sessionize.com/image/d14d-400o400o2-pyB229HyFPCnUcZhHf3kWS.png",
            "twitter": "@__oscarnewman",
            "notes": null,
            "favorite": null,
            "createdAt": "2024-12-12T08:22:03.956Z",
            "updatedAt": "2024-12-12T08:22:03.956Z",
            "publishedAt": "2024-12-12T08:22:03.961Z"
        },
        {
            "id": 11,
            "documentId": "n6yoo2mil4a36kpia4qy2x03",
            "first": "Muhammad",
            "last": "Syakirurohman",
            "avatar": "https://devaradise.com/_astro/syakir.JYhBotXK_1ltmJh.webp",
            "twitter": "@syakirurohman",
            "notes": null,
            "favorite": true,
            "createdAt": "2024-12-13T23:47:36.654Z",
            "updatedAt": "2024-12-13T23:58:48.752Z",
            "publishedAt": "2024-12-13T23:58:48.758Z"
        }
    ],
    "meta": {
        "pagination": {
            "page": 1,
            "pageSize": 25,
            "pageCount": 1,
            "total": 4
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Fetching All Contacts from Strapi

Previously, our data source for the contact list was dummy data stored as a variable in data.ts.

Now, we will refactor data.ts to fetch the Contact collection from Strapi.

  1. Rename data.ts to data.server.ts. This ensures it runs only on the server side. Make sure to update all import statements for data.server in other files accordingly.
  2. We add documentId field to ContactMutation type since every data from Strapi version 5 is automatically added this field. The documentId will be used to get contact detail from Strapi.

  3. Refactor the data.server.ts by removing all code related to dummy data. Clear all function bodies and add a STRAPI_BASE_URL variable to store the Strapi API base URL.

type ContactMutation = {
  id?: string;
  documentId?: string;
  first?: string;
  last?: string;
  avatar?: string;
  twitter?: string;
  notes?: string;
  favorite?: boolean;
};

export type ContactRecord = ContactMutation & {
  id: string;
  createdAt: string;
};

const STRAPI_BASE_URL = process.env.STRAPI_BASE_URL || 'http://localhost:1337'

export async function getContacts(query?: string | null) {
}

export async function createEmptyContact() {
}

export async function getContact(id: string) {
}

export async function updateContact(id: string, updates: ContactMutation) {
}

export async function deleteContact(id: string) {
}
Enter fullscreen mode Exit fullscreen mode

To fetch all contacts from Strapi, let's redefine the getContacts function.

export async function getContacts(query?: string | null) {
  try {
    const response = await fetch(STRAPI_BASE_URL + "/api/contacts")
    const json = await response.json()
    return json.data as ContactMutation[]
  } catch (err) {
    console.log(err)
  }
}
Enter fullscreen mode Exit fullscreen mode

After that, go to root.tsx. Make sure you change the import source for getContacts function.

import { getContacts } from "./data.server";
Enter fullscreen mode Exit fullscreen mode

Save and reload your app homepage. You will see that now it fetch the contacts from Strapi.

Contact list from Strapi.jpg

Fetching Single Contact

Since we have refactored data.ts, the contact detail page is temporary not working. So, let's fix it so it gets the contact data from Strapi contact.

Still in the root.tsx, Scroll to the nav tag where we iterate the contacts variable, change the ${contact.id} to ${contact.documentId}

...
<nav>
    {contacts?.length ? (
      <ul>
        {contacts.map((contact) => (
          <li key={contact.id}>
            <Link to={`contacts/${contact.documentId}`}>
              {contact.first || contact.last ? (
                <>
                  {contact.first} {contact.last}
                </>
              ) : (
                <i>No Name</i>
              )}{" "}
              {contact.favorite ? (
                <span></span>
              ) : null}
            </Link>
          </li>
        ))}
      </ul>
    ) : (
      <p>
        <i>No contacts</i>
      </p>
    )}
</nav>
...
Enter fullscreen mode Exit fullscreen mode

This will change the contactId param in contact detail page.

Navigate to data.server.ts and redefine the getContact function as follows

export async function getContact(documentId: string) {
    try {
    const response = await fetch(STRAPI_BASE_URL + "/api/contacts/" + documentId);
    const json = await response.json()
    return json.data
  } catch (err) {
    console.log(err)
  }
}
Enter fullscreen mode Exit fullscreen mode

In ./routes/contacts.$contactId.tsx, make sure you change the import source for getContact function to ./data.server.

import { getContacts } from "./data.server";
Enter fullscreen mode Exit fullscreen mode

Save and reload the app. Click on a contact in the contact list, and it should render the contact detail page with data fetched from Strapi.

Strapi contact detail page.jpg

Create Contact Functionality

Next, let's add the Create Contact functionality.

We already have a New button beside the search field in the sidebar. Currently, clicking it returns a 405 error.

Now, let's change this button to a link that redirects to the create page.

In root.tsx, locate the following code:

...
<Form method="post">
  <button type="submit">New</button>
</Form>
...
Enter fullscreen mode Exit fullscreen mode

Change it to

<Link to="contacts/create" className="buttonLink">Create</Link>
Enter fullscreen mode Exit fullscreen mode

Add this CSS style to app.css to style the Create link

.buttonLink {
  font-size: 1rem;
  font-family: inherit;
  border: none;
  border-radius: 8px;
  padding: 0.5rem 0.75rem;
  box-shadow: 0 0px 1px hsla(0, 0%, 0%, 0.2), 0 1px 2px hsla(0, 0%, 0%, 0.2);
  background-color: white;
  line-height: 1.5;
  margin: 0;
  color: #3992ff;
  font-weight: 500;
  text-decoration: none;
}
Enter fullscreen mode Exit fullscreen mode

Now, let's create a new page component form Create contact page.

Add new file in routes folder ./routes/contacts.create.tsx. Copy and paste this Create form component.

import { useNavigate, Form } from "@remix-run/react";

export default function CreateContact() {
  const navigate = useNavigate();

  return (
    <Form method="post">
      <div className="create-form-grid">
        <FormInput
          aria-label="First name"
          name="first"
          type="text"
          label="First name"
          placeholder="First"
        />
        <FormInput
          aria-label="Last name"
          name="last"
          type="text"
          label="Last name"
          placeholder="Last"
        />
        <FormInput
          name="twitter"
          type="text"
          label="Twitter"
          placeholder="@jack"
        />
        <FormInput
          aria-label="Avatar URL"
          name="avatar"
          type="text"
          label="Avatar URL"
          placeholder="https://example.com/avatar.jpg"
        />
      </div>
      <br/>
      <div className="input-field">
        <label htmlFor="notes">Notes</label>
        <textarea id="notes" name="notes" rows={6} />
      </div>

      <div className="button-group">
        <button type="submit">Create</button>
        <button type="button" onClick={() => navigate(-1)}>
          Cancel
        </button>
      </div>
    </Form>
  );
}

function FormInput({
  type,
  name,
  label,
  placeholder,
  defaultValue = "",
  errors,
}: Readonly<{
  type: string;
  name: string;
  label?: string;
  placeholder?: string;
  errors?: Record<string, string[]>;
  defaultValue?: string;
}>) {
  return (
    <div className="input-field">
      <div>
        <label htmlFor={name}>{label}</label>
        <div>
          <input
            name={name}
            type={type}
            placeholder={placeholder}
            defaultValue={defaultValue}
          />
        </div>
      </div>
      {errors && errors[name] &&
        <ul>
          {errors[name].map((error: string) => (
            <li key={error} className="input-error">
              {error}
            </li>
          ))}
        </ul>
      }
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Add this CSS blocks to ./app.css as well for styling.

.button-group {
  margin-top: 1rem;
  display: flex;
  gap: 0.5rem;
}

.input-field input, .input-field textarea {
  width: 100%;
  display: block;
}

.input-error {
  color: #e53e3e;
  font-size: 0.875rem;
  margin-top: 0.25rem;
}

.create-form-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  grid-template-rows: auto auto;
  gap: 20px;
}

.root-error {
  background: red;
  color: white;
  padding: 6rem;
}

.contact-error {
  padding: 6rem;
}
Enter fullscreen mode Exit fullscreen mode

Save the filees, and navigate to Create contact page by clicking on Create button.

Contact form component.jpg

Before proceeding further, let's understand how the Remix <Form> component works.

The native HTML <form> have at least two attributes: action and method. Upon submission, the form sends an HTTP request to the URL specified in the action attribute using the HTTP method defined in the method attribute.

If the action attribute is not defined, it will send the request to the same page.

The Remix <Form> component follows the same approach. Any Remix route can have an action function to handle the form request sent to that route.

A Remix action is a special function in your route file that manages data mutations, such as creating, updating, or deleting data, triggered by form submissions or specific server requests.

Remember the loader function we used to fetch data? The action function is similar, but it is used for data mutation. It also has to be explicitly named action in your route file.

You also don't have to manually control the state of each field. Remix <Form> uses the standard Web API (FormData) to handle forms. Just make sure that all form fields have name attributes.

Now, let's add the codes to handle the Contact form submission.

First, go to the data.server.ts. Change the createEmptyContact function to createContact and add the codes to add new record to Strapi Contact collection.

// in ./data.server.ts
export async function createContact(data: Record<string, unknown>) {
  try {
    const response = await fetch(STRAPI_BASE_URL + "/api/contacts", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ data }),
    });
    const json = await response.json()
    return json.data
  } catch (err) {
    console.log(err)
  }
}
Enter fullscreen mode Exit fullscreen mode

In the code above, we use the same fetch function to connect to the Strapi API. This time, we specify the "POST" method and include the data in the request body.

Now, return to ./routes/contacts.create.tsx and add the action function.

import { useNavigate, Form } from "@remix-run/react";
import { type ActionFunctionArgs, redirect } from "@remix-run/node";
import { createContact } from "./../data.server";

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const data = Object.fromEntries(formData);
  const newEntry = await createContact(data);

  return redirect("/contacts/" + newEntry.documentId)
}

export default function CreateContact() {
    // nothing changed in this block for now
    //...
}
Enter fullscreen mode Exit fullscreen mode

In the code above, we added the action function for this route. Here's how it works:

  1. The form submission data is captured in the { request } parameter.
  2. The createContact function is called to add a new record to the Strapi Contact collection using the submitted form data.
  3. If the contact creation is successful, it redirects to the detail page of the newly created contact using the redirect function provided by Remix.

Save your changes and reload the app. Try adding new contact and see how it works.

Form validation with Zod and Remix

Although we have added the Create contact functionality, it doesnt have the form validation. You still can input anything without check.

Even if you submit a blank input, it will still create a new contact.

So, in this section we will add form validation using Zod.

Zod is a TypeScript-first library for schema declaration and validation, offering a simple API to define, parse, and validate data structures with runtime type safety. It supports detailed error handling, and is ideal for validating inputs, APIs, and complex data in modern applications.

Install Zod in your Remix project by running:

npm i zod

Make sure you execute this command in the remix folder via terminal.

Next, import zod into contacts.create.tsx and define a schema within the action function.

import { useNavigate, Form } from "@remix-run/react";
import { type ActionFunctionArgs, redirect } from "@remix-run/node";
import { createContact } from "./../data.server";
import * as z from "zod";

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const data = Object.fromEntries(formData);

  const formSchema = z.object({
    avatar: z.string().url().min(2),
    first: z.string().min(2),
    last: z.string().min(2),
    twitter: z.string().min(2),
  });

  const validatedFields = formSchema.safeParse({
    avatar: data.avatar,
    first: data.first,
    last: data.last,
    twitter: data.twitter,
  });

  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
      message: "Please fill out all missing fields.",
      data: null,
    }
  }

  const newEntry = await createContact(data);  
  return redirect("/contacts/" + newEntry.documentId)
}
export default function CreateContact() {
// Nothing changed yet here
//...
}
Enter fullscreen mode Exit fullscreen mode

In the code above:

  • We introduced the formSchema variable, which defines the validation rules for each field in the Create contact form.
  • We parse the input data from the Create contact form request using formSchema.safeParse() and store the result in the validateFields variable.
  • We added a conditional check: if validateFields is unsuccessful, the action function returns an error object and halts the execution of the createContact function.

Save your changes, and try adding a contact again with blank inputs.

You will notice that it's not creating a new contact anymore. The form is actually returning errors from zod validation.

Now, let's show these error messages in our contact form.

In the same file contacts.create.tsx, within the the component function CreateContact:

import { useNavigate, Form, useActionData } from "@remix-run/react";
import { type ActionFunctionArgs, redirect } from "@remix-run/node";
import { createContact } from "./../data.server";
import * as z from "zod";

export async function action({ request }: ActionFunctionArgs) {
  // nothing changed here
}
export default function CreateContact() {
  const navigate = useNavigate();
  const formData = useActionData<typeof action>()

  return (
    <Form method="post">
      <div className="create-form-grid">
        <FormInput
          aria-label="First name"
          name="first"
          type="text"
          label="First name"
          placeholder="First"
          errors={formData?.errors}
        />
        <FormInput
          aria-label="Last name"
          name="last"
          type="text"
          label="Last name"
          placeholder="Last"
          errors={formData?.errors}
        />
        <FormInput
          name="twitter"
          type="text"
          label="Twitter"
          placeholder="@jack"
          errors={formData?.errors}
        />
        <FormInput
          aria-label="Avatar URL"
          name="avatar"
          type="text"
          label="Avatar URL"
          placeholder="https://example.com/avatar.jpg"
          errors={formData?.errors}
        />
      </div>
      <br/>
      <div className="input-field">
        <label htmlFor="notes">Notes</label>
        <textarea id="notes" name="notes" rows={6} />
      </div>

      <div className="button-group">
        <button type="submit">Create</button>
        <button type="button" onClick={() => navigate(-1)}>
          Cancel
        </button>
      </div>
    </Form>
  );
}
//...
Enter fullscreen mode Exit fullscreen mode

In the code above:

  • We use useActionData to fetch the form request result and assign it to the formData variable. useActionData is imported from @remix-run/react.
  • We pass the formData?.errors to the FormInput component's errors attribute. FormInput will handle and display the error messages.

Save your changes and try submitting blank inputs again in Create contact form. It will now showing the error messages.

Contact form error messages.jpg

Update Contact Functionality

In this section, we are going to add update contact functionality.

First, let's create a new file in /routes folder, named contacts.$contactId_.edit.tsx

Note the weird _ in $contactId_. By default, routes will automatically nest inside routes with the same prefixed name. Adding a trailing _ tells the route to not nest inside app/routes/contacts.$contactId.tsx. You can read more about this in the Route File Naming official docs.

For the sake of brevity, we will use the same component and form validation we added in contacts.create.tsx.

So, copy all the codes in contacts.create.tsx and paste it to contacts.$contactId_.edit.tsx.

Then, make this changes

import { useNavigate, Form, useActionData, useLoaderData } from "@remix-run/react";
import { type ActionFunctionArgs, LoaderFunctionArgs, redirect } from "@remix-run/node";
import { updateContact, getContact } from "./../data.server";
import * as z from "zod";
import invariant from "tiny-invariant";

export const loader = async ({
  params,
}: LoaderFunctionArgs) => {
  invariant(params.contactId, "Missing contactId param");
  const contact = await getContact(params.contactId);
  if (!contact) {
    throw new Response("Not Found", { status: 404 });
  }
  return { contact };
};
export async function action({ params, request }: ActionFunctionArgs) {
  invariant(params.contactId, "Missing contactId param");

  const formData = await request.formData();
  const data = Object.fromEntries(formData);

  const formSchema = z.object({
    avatar: z.string().url().min(2),
    first: z.string().min(2),
    last: z.string().min(2),
    twitter: z.string().min(2),
  });

  const validatedFields = formSchema.safeParse({
    avatar: data.avatar,
    first: data.first,
    last: data.last,
    twitter: data.twitter,
  });

  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
      message: "Please fill out all missing fields.",
      data: null,
    }
  }

  const updatedEntry = await updateContact(params.contactId, data);  
  return redirect("/contacts/" + updatedEntry.documentId)
}
export default function EditContact() {
  const navigate = useNavigate();
  const { contact } = useLoaderData<typeof loader>();
  const formData = useActionData<typeof action>();

  return (
    <Form method="post">
      <div className="create-form-grid">
        <FormInput
          aria-label="First name"
          name="first"
          type="text"
          label="First name"
          placeholder="First"
          defaultValue={contact?.first}
          errors={formData?.errors}
        />
        <FormInput
          aria-label="Last name"
          name="last"
          type="text"
          label="Last name"
          placeholder="Last"
          defaultValue={contact?.last}
          errors={formData?.errors}
        />
        <FormInput
          name="twitter"
          type="text"
          label="Twitter"
          placeholder="@jack"
          defaultValue={contact?.twitter}
          errors={formData?.errors}
        />
        <FormInput
          aria-label="Avatar URL"
          name="avatar"
          type="text"
          label="Avatar URL"
          placeholder="https://example.com/avatar.jpg"
          defaultValue={contact?.avatar}
          errors={formData?.errors}
        />
      </div>
      <br/>
      <div className="input-field">
        <label htmlFor="notes">Notes</label>
        <textarea id="notes" name="notes" rows={6} defaultValue={contact?.notes} />
      </div>

      <div className="button-group">
        <button type="submit">Update</button>
        <button type="button" onClick={() => navigate(-1)}>
          Cancel
        </button>
      </div>
    </Form>
  );
}

// nothing change in FormInput component
function FormInput(){
    ///...
}
Enter fullscreen mode Exit fullscreen mode

In the code above:

  • We added a loader function to load the existing contact, similar to the one in contacts.$contactId.tsx.
  • In the action function, we included params in the arguments to fetch params.contactId. This will be passed to the updateContact function as an argument.
  • We renamed the component to EditContact. Using useLoaderData, we accessed the loaded contact data and prefilled the contact fields in <FormInput> and <textarea> by setting their defaultValue attributes.

Edit contact page.jpg

The Update Contact page now renders a contact form with prefilled information. However, the update contact functionality is not working yet.

We still need to implement the API call to Strapi in the updateContact function within ./data.server.ts. Update the updateContact function as follows:

//...
export async function updateContact(documentId: string, updates: ContactMutation) {
  try {
    const response = await fetch(STRAPI_BASE_URL + "/api/contacts/" + documentId, {
      method: "PUT",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ data: { ...updates} }),
    });
    const json = await response.json();
    return json.data;
  } catch (error) {
    console.log(error);
  }
}
//...
Enter fullscreen mode Exit fullscreen mode

In the code above, we use the same fetch function to connect to the Strapi API. This time, we specify the "PUT" method and include the updated data in the request body.

Save your code, and try updating a contact. It should be working now.

Implement Delete Contact Functionality

As we have added create and update contact functionality, let's also implement the delete contact functionality.

Earlier, in the contact detail page contact.$contactId.tsx, we added a delete button wrapped in the Remix <Form> component.

<Form
    action="delete"
    method="post"
    onSubmit={(event) => {
      const response = confirm(
        "Please confirm you want to delete this record."
      );
      if (!response) {
        event.preventDefault();
      }
    }}
    >
    <button type="submit">Delete</button>
</Form>
Enter fullscreen mode Exit fullscreen mode

Upon clicking the Delete button, it prompts a native browser alert to confirm your action.

If you confirm, the form will submit a request to /contacts/${documentId}/delete, as defined in the action attribute, and throw a 405 error.

Delete contact 405 error.jpg

This happened because we haven't created a delete route yet to handle this request.

Let us now create a new route file named contacts.$contactId.delete.tsx inside /routes folder.

In Remix, we can have a route that only handles a request, without a UI. This is what we will do with the delete route.

We only need to add an action function to handle the delete request.

import { type ActionFunctionArgs, redirect } from "@remix-run/node";
import invariant from "tiny-invariant";
import { deleteContact } from "../data.server";

export const action = async ({ params } : ActionFunctionArgs ) => {
  invariant(params.contactId, "Missing contactId param");
  await deleteContact(params.contactId);
  return redirect("/contacts")
}
Enter fullscreen mode Exit fullscreen mode

In the code above:

  • We check the params.contactId with invariant, as we also do in contact detail page and update contact action.
  • We called deleteContact from ./data.server to execute the delete contact functionality
  • If deletion success we will redirect the page route to /contacts

In the ./data.server.ts, we also need to update the deleteContact function as follows:

//...
export async function deleteContact(documentId: string) {
  try {
    const response = await fetch(STRAPI_BASE_URL + "/api/contacts/" + documentId, {
      method: "DELETE",
    });
    const json = await response.json();
    return json.data;
  } catch (error) {
    console.log(error);
  }
}
//...
Enter fullscreen mode Exit fullscreen mode

In the code above, we call the Strapi endpoint /api/contact/${documentId} with the DELETE method to delete the contact with the corresponding documentId. The documentId matches the contactId used in the URL.

Save your code and try deleting a contact. If successful, you will be redirected to the /contacts page.

Contacts not found.jpg

You'll notice it shows a 404 Not Found error because we don't have a route page for /contacts.

Setting up Fallback page when no contact selected

Let's add a simple component for the /contacts route.

Create a new file named contacts_.tsx in the /routes folder and paste this simple component:

export default function Contacts() {
  return (
    <div>This will show up when no items are selected</div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Don't forget the _ in the contacts_.tsx filename. It's necessary to prevent it from being rendered in its child routes (e.g., /contacts/${contactId}).

Now, the /contacts route should display like this:

Contacts page.jpg

Handling Error in Remix

In real-world applications, errors are inevitable, such as when a requested page is not found.

By default, Remix provides a basic 404 Not Found error page to handle such scenarios.

We can customize this error handling by creating and exporting a component named ErrorBoundary in any route page where we want to manage errors.

Let's try this by adding ErrorBoundary component in root.tsx to customize root error boundary.

export function ErrorBoundary() {
  const error = useRouteError();
  return (
    <html lang="en">
      <head>
        <title>Oh no!</title>
        <Meta />
        <Links />
      </head>
      <body>
        <div style={{ padding: '3rem'}}>
          <h2>Oh no! Something went wrong</h2>
          {
            isRouteErrorResponse(error) && <div>
              <div><strong>{error.status} Error</strong></div>
              <div>{error.data}</div>
              <br/>
              <Link to="/" className="buttonLink">Go back to home</Link>
            </div>
          }
        </div>
        <Scripts />
      </body>
    </html>
  );
}

export default function App() {
// nothing changed here
}

Enter fullscreen mode Exit fullscreen mode

Dont forget to add ErrorBoundary and isRouteErrorResponse to the import code.

import {
  Form,
  isRouteErrorResponse,
  Link,
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
  useLoaderData,
  useRouteError,
} from "@remix-run/react";

//...
Enter fullscreen mode Exit fullscreen mode

Now, save the file and try accessing the app with a random URL. The custom error boundary we just implemented will be displayed.

Custom error boundary for route.jpg

Apart from root.tsx, we can also add a custom error boundary to any route.

So, let's add an error boundary again to contact detail page.

In contacts.$contactId.tsx, add the following codes above the Contact() component

export function ErrorBoundary() {
  const error = useRouteError();
  return (
    <div style={{ padding: '3rem'}}>
      <h2>Oh no! Something went wrong</h2>
      {
        isRouteErrorResponse(error) && <div>
          <div><strong>{error.status} Error</strong></div>
          <div>{error.data}</div>
          <br/>
          <Link to="/" className="buttonLink">Go back to home</Link>
        </div>
      }
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Dont forget to import ErrorBoundary and isRouteErrorResponse from @remix-run/react as well, so the updated import code is as follows:

import { Form, isRouteErrorResponse, Link, useLoaderData, useRouteError } from "@remix-run/react";
Enter fullscreen mode Exit fullscreen mode

In the loader function in contact.$contactId.tsx, there is an error thrown when the contact is not found in the Strapi API.

//...
export const loader = async ({
  params,
}: LoaderFunctionArgs) => {
  invariant(params.contactId, "Missing contactId param");
  const contact = await getContact(params.contactId);
  if (!contact) {
    throw new Response("Not Found", { status: 404});
  }
  return { contact };
};
//...
Enter fullscreen mode Exit fullscreen mode

The "Not Found" text on line 8 in the code above will be passed to error.data in the ErrorBoundary component.

Change "Not Found" to "Contact Not Found".

Save the file, then access a contact detail page using a nonexistingid. The error page will now display as follows:

Contact not found error.jpg

Mark Contact as Favorite

In the contact detail page, contact.$contactId.tsx, we have already included a <Favorite> component.

const Favorite: FunctionComponent<{
  contact: Pick<ContactRecord, "favorite">;
}> = ({ contact }) => {
  const favorite = contact.favorite;

  return (
    <Form method="post">
      <button
        aria-label={
          favorite
            ? "Remove from favorites"
            : "Add to favorites"
        }
        name="favorite"
        value={favorite ? "false" : "true"}
      >
        {favorite ? "" : ""}
      </button>
    </Form>
  );
};
Enter fullscreen mode Exit fullscreen mode

The <Favorite> component is functioning as a form. When you click the star icon, it sends a POST request to the contact detail page. This request contains a favorite field with a value of true or false, as specified in the button's name and value attributes.

Currently, it does nothing because we haven't handled this request. To make it functional, we need to add an action function to contact.$contactId.tsx that updates the favorite field of the contact.

For this, we will reuse the same updateContact function that we utilized in the Update Contact page.

//...
export async function action({ params, request} : ActionFunctionArgs ) {
  invariant(params.contactId, "Missing contactId param");
  const formData = await request.formData();
  return updateContact(params.contactId, {
    favorite: formData.get("favorite") === "true"
  })
}

//...
Enter fullscreen mode Exit fullscreen mode

Dont forget to also import ActionFunctionArgs and updateContact.

// other imports
// ...

import { getContact, updateContact, type ContactRecord } from "../data.server";
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";

//...
Enter fullscreen mode Exit fullscreen mode

Save your changes, and try to favorite or unfavorite the contact. You will see that now you can star or unstar a contact.

Search Functionality

The search form has been part of the UI since the beginning of this tutorial, but its functionality hasn't been implemented yet.

Before we proceed to implement it, let’s take a closer look at the Strapi API used to fetch the contact list, specifically the GET /api/contacts endpoint.

If you access this endpoint in your browser locally at http://localhost:1337/api/contacts, you will receive a JSON response.

{
    "data": [
        {
            "id": 34,
            "documentId": "y1s5z2oqmmppu1d9n7wmelc9",
            "first": "Ryan",
            "last": "Florence",
            "avatar": "https://sessionize.com/image/9273-400o400o2-3tyrUE3HjsCHJLU5aUJCja.jpg",
            "twitter": null,
            "notes": null,
            "favorite": false,
            "createdAt": "2024-12-12T08:21:41.582Z",
            "updatedAt": "2024-12-18T12:50:16.890Z",
            "publishedAt": "2024-12-18T12:50:16.896Z"
        },
       // ...other contacts
    ],
    "meta": {
        "pagination": {
            "page": 1,
            "pageSize": 25,
            "pageCount": 1,
            "total": 4
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

At the end of the JSON response, you’ll notice a meta field containing a pagination object.

By default, Strapi limits the response from collection list APIs, such as GET /api/contacts, to a maximum of 25 rows, as indicated by the pageSize property. Additionally, the results are sorted by the updatedAt field in ascending order.

If our contact list contains more than 25 rows, the sidebar won't display all the contacts. In real-world applications, this scenario is typically addressed by implementing pagination logic.

For simplicity, we'll modify the API query to increase the pageSize to 50. Additionally, we'll sort the contact list by the createdAt field in descending order so that the newest contacts appear at the top of the list.

After that, we will implement the search filter by keyword.

All these functionalities will be achieved by passing query parameters to the Strapi endpoints, like this:

/api/contacts?sort[0]=createdAt:desc&filters[first][$contains]=syakir&filters[last][$contains]=syakir&pagination[pageSize]=50&pagination[page]=1
Enter fullscreen mode Exit fullscreen mode

As you can see, the URL is not human-readable. To simplify this process, we will use the qs library to pass the query parameters in JSON format. The library will automatically build the query string in URL format based on the JSON object we provide.

You can learn more about this in the Strapi interactive query builder.

Run the following command to install the qs library in your Remix project. Make sure you're in the remix folder in your terminal:

npm i qs
npm i @types/qs --save-dev
Enter fullscreen mode Exit fullscreen mode

Go to ./data.server.ts. In the top of file, import qs.

import qs from "qs"
Enter fullscreen mode Exit fullscreen mode

Then, update getContacts function.

//...
export async function getContacts(q?: string | null) {
  const query = qs.stringify({
    sort: 'createdAt:desc',
    filters: {
      $or: [
        { first: { $contains: q }},
        { last: { $contains: q }},
        { twitter: { $contains: q }},
      ]
    },
    pagination: {
      pageSize: 50,
      page: 1,
    },
  })

  try {
    const response = await fetch(STRAPI_BASE_URL + "/api/contacts?" + query)
    const json = await response.json()
    return json.data as ContactMutation[]
  } catch (err) {
    console.log(err)
  }
}
//...
Enter fullscreen mode Exit fullscreen mode

In the code above, we implemented a query builder to:

  • Sort the contact list by the createdAt column in descending order.
  • Filter contacts based on keywords contained in the first, last, and twitter fields.
  • Set the pagination pageSize to 50.

At this stage, sorting by createdAt and increasing rows limit per page to 50 is functional. You can test it by adding a new contact, which will appear at the top of the list.

The search functionality, however, is not yet working. We still need to pass the search keyword from the search form to the getContacts function.

Go to ./root.tsx. Update the codes as follows.

import {
  Form,
  isRouteErrorResponse,
  Link,
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
  useLoaderData,
  useRouteError,
  useSubmit,
} from "@remix-run/react";
import { LoaderFunctionArgs, type LinksFunction } from "@remix-run/node";
import appStyleHref from './app.css?url'
import { getContacts } from "./data.server";
export const links: LinksFunction = () => [
  { rel:"stylesheet", href: appStyleHref }
]

export const loader = async ({ request }: LoaderFunctionArgs) => {
  const url = new URL(request.url);
  const q = url.searchParams.get("q");
  const contacts = await getContacts(q);
  return { contacts, q }
};

export function ErrorBoundary() {
    // nothing changed in error boundary
}

export default function App() {
  const { contacts, q } = useLoaderData<typeof loader>();
  const submit = useSubmit();

  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
      </head>
      <body>
        <div id="sidebar">
          <h1>Remix Contacts</h1>
          <div>
            <Form id="search-form" role="search" onChange={(e) => submit(e.currentTarget)}>
              <input
                id="q"
                aria-label="Search contacts"
                placeholder="Search"
                type="search"
                name="q"
                defaultValue={q || ''}
              />
              <div id="search-spinner" aria-hidden hidden={true} />
            </Form>
            // nothing changed after this code            
        </div>
        //... other codes
      </body>
    </html>
  );
}

Enter fullscreen mode Exit fullscreen mode

In the code above:

  • The loader function was updated to extract the ?q= query parameter from the URL, which contains the search keyword from the search form, and assigns it to the q variable.
  • The q variable was included in the return value so that it can be used to synchronize the search form field with the q query parameter in the URL.
  • The useSubmit hook was used in the App() component to handle submission from the search form. This hook was assigned to the onChange event handler in the search form, enabling real-time submission of the search request when the search input changes.

Now, try the search input—it should work in real-time!

Add loading state to Search

When user internet connection is slow, you'll notice that there will be some delay or lag in search funtionality. You can simulate this by changing the internet connection speed to 3G in Devtools > Network Tab in your browser

Internet connection speed.jpg

To handle the waiting state for the Strapi API to respond, we'll add the loading state to search form.

We will also refactor a bit on how we submit the search form request. Our search functionality is creating a lot of history stack because it push a new route in every keystroke, when we submitted the form.

So, we will use replace method to solve this.

In root.tsx, update the App component function.

// add new import `useNavigation`
import {
  Form,
  isRouteErrorResponse,
  Link,
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
  useLoaderData,
  useNavigation,
  useRouteError,
  useSubmit,
} from "@remix-run/react";
//.. other imports

export const loader = async ({ request }: LoaderFunctionArgs) => {
  // nothing changed here
}

export function ErrorBoundary() {
    // nothing changed here
}

export default function App() {
  const { contacts, q } = useLoaderData<typeof loader>();
  const submit = useSubmit();
  const navigation = useNavigation();
  const searching = navigation.location && new URLSearchParams(navigation.location.search).has("q");

  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
      </head>
      <body>
        <div id="sidebar">
          <h1>Remix Contacts</h1>
          <div>
            <Form id="search-form" role="search" onChange={(e) => {
              const isFirstSearch = q === null;
              submit(e.currentTarget, {
                replace: !isFirstSearch,
              });
            }}>
              <input
                id="q"
                className={searching ? 'loading' : ''}
                aria-label="Search contacts"
                placeholder="Search" 
                type="search"
                name="q"
                defaultValue={q || ''}
              />
              <div id="search-spinner" aria-hidden hidden={!searching} />
            </Form>
            <Link to="contacts/create" className="buttonLink">Create</Link>
          </div>
          //... other codes, nothing changed here
        </div>
          //... other codes, nothing changed here
      </body>
    </html>
  );
}

Enter fullscreen mode Exit fullscreen mode

In the code above:

  • useNavigation was imported to access the navigation state, which will be used to evaluate a loading state.
  • A searching variable was declared to evaluate the presence of a query parameter and the navigation state. This variable is used to determine if the search is currently loading.
  • The onChange function for the search form was updated to ensure that the submit function replaces the existing browser history stack instead of pushing a new one with each keystroke.
  • The searching variable was applied to conditionally add a loading class to the input field and to toggle the visibility of the search spinner.

Save the changes and test the search feature again on a slow connection to observe the loading state in action.

Project Review and Challenges

We have implemented a fully-functional contact app with Remix as Front-end and Strapi as backend. You can create, update, delete and search the contact data from strapi directly in our app.

But, the project is far from perfect. There is always some room for improvement

In a real-world app, you might want to optimize your app for better user experience. So, i want you to implement these challenges to optimize our contact app.

The answer for these challenges are still included in project code in Github, but i want you to be more creative and solve the problem on your own.

I will provide some hints and related links to the official remix documentation

Implement debounce in search functionality

In the current search contact functionality, the app makes an API call to Strapi each time the user types a letter in the search input. For instance, when searching for the name Jane, the app will actually search for J, Ja, Jan, and Jane.

In a real-world application, this behavior can lead to unnecessary operations and resource wastage.

Your task is to optimize this by ensuring the search form only hits the Strapi API endpoint after the user stops typing in the search input, rather than on every keystroke.

Hint:

  • Refactor the search form's onChange function to implement a JavaScript debounce mechanism.

Highlight Selected Contact in sidebar

When a contact is selected and the contact detail page is opened, the sidebar contact list does not indicate which contact is currently selected.

Your task is to highlight the selected contact. We already have the active class for the selected contact link and the pending class for transitions. Apply these two classes conditionally to the contact link.

Hint:

  • Use the Remix NavLink component to access the active and pending states.

Using useFetcher for Favorite / Unfavorite Contact

Currently, when you click to favorite or unfavorite a contact, the app performs a form submission and refreshes the page.

Your task is to prevent the page from reloading or submitting the form natively by using Remix's useFetcher in the favorite form component.

Link:

Handling Error from Backend

We are already handling errors using the ErrorBoundary component. However, the error messages and statuses are either default Remix messages or those defined in the action function.

Your task is to handle errors originating from Strapi and display the Strapi error message in the ErrorBoundary component.

Hint:

  • Throw the error inside the catch block in the function located in ./data/server. Include the Strapi error message in your error object.

Link:

Project codes

You can find complete project codes in this link: Github repository.

Conclusion

We have successfully built a fully functional contact management app using Remix for the front end and Strapi headless CMS as the backend. The app enables users to create, update, delete, and search data in real-time, providing a solid foundation for your future project.

Throughout the development process, we implemented Remix key features such as dynamic route handling, state management, error boundaries, and API integration.

Next, you should keep practicing what you have learn here by creating your own project, and improvise it. Make the official documentation your friend.

Follow the Strapi blog for more exciting tutorials and blog posts like this

Top comments (4)

Collapse
 
anmolbaranwal profile image
Anmol Baranwal

This is really detailed man! 🔥

Collapse
 
syakirurahman profile image
Syakir

2 projects in 1 repo. So, yeah 😆

Collapse
 
pathofbuilding profile image
pathofBuilding

This step-by-step guide to building a contact app with Remix and Strapi is super helpful, especially for beginners like me. I really appreciate the clear explanations and the structure of the app. Setting up both the frontend and backend felt smooth, and the integration was seamless. I particularly liked the section on dynamic routing with $contactId — it really helped me understand how to work with dynamic routes in Remix. Also, the error handling and loading states are a nice touch for improving user experience!
For anyone looking to enhance their app building experience further, don't forget to check out Path of Building. It's a fantastic resource!

Collapse
 
syakirurahman profile image
Syakir

Thanks for your feedback.