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.
Before starting this Remix tutorial, make sure you have the following:
In this tutorial, you will learn:
useNavigate
for navigation actions.useFetcher
for better interactions.In this tutorial, we’ll build a simple contact management app with features:
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
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
Then, set up a remix project by running this command:
1npx create-remix@latest --template remix-run/remix/templates/remix-tutorial
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
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.
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 operationsapp.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
The Remix app will start on localhost:5173
, but you will see an unstyled page.
This is because we haven't imported the app.css
file in root.tsx
.
Let's import the app.css
in root.tsx
by adding the code on lines 9–13 as shown below.
1import {
2 Form,
3 Links,
4 Meta,
5 Scripts,
6 ScrollRestoration,
7} from "@remix-run/react";
8
9import type { LinksFunction } from "@remix-run/node";
10import appStyleHref from './app.css?url'
11export const links: LinksFunction = () => [
12 { rel:"stylesheet", href: appStyleHref }
13]
14
15export default function App() {
16 return (
17 <html lang="en">
18 <head>
19 <meta charSet="utf-8" />
20 <meta name="viewport" content="width=device-width, initial-scale=1" />
21 <Meta />
22 <Links />
23 </head>
24 <body>
25 <div id="sidebar">
26 ...
27 </div>
28 <ScrollRestoration />
29 <Scripts />
30 </body>
31 </html>
32 );
33}
Refresh the app, and it will render the styles.
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.
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.
Then, we have to add the <Outlet />
component in the root.tsx
to render the route component.
1import {
2 Form,
3 Links,
4 Meta,
5 Outlet,
6 Scripts,
7 ScrollRestoration,
8} from "@remix-run/react";
9
10import type { LinksFunction } from "@remix-run/node";
11import appStyleHref from './app.css'
12export const links: LinksFunction = () => [
13 { rel:"stylesheet", href: appStyleHref }
14]
15
16export default function App() {
17 return (
18 <html lang="en">
19 <head>
20 <meta charSet="utf-8" />
21 <meta name="viewport" content="width=device-width, initial-scale=1" />
22 <Meta />
23 <Links />
24 </head>
25 <body>
26 <div id="sidebar">
27 ...
28 </div>
29
30 <div id="detail">
31 <Outlet />
32 </div>
33
34 <ScrollRestoration />
35 <Scripts />
36 </body>
37 </html>
38 );
39}
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.
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.
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.
1import { Form, Link } from "@remix-run/react";
2import type { FunctionComponent } from "react";
3
4import type { ContactRecord } from "../data";
5
6export default function Contact() {
7 const contact = {
8 first: "Your",
9 last: "Name",
10 avatar: "https://placecats.com/200/200",
11 twitter: "your_handle",
12 notes: "Some notes",
13 favorite: true,
14 };
15
16 return (
17 <div id="contact">
18 <div>
19 <img
20 alt={`${contact.first} ${contact.last} avatar`}
21 key={contact.avatar}
22 src={contact.avatar}
23 />
24 </div>
25
26 <div>
27 <h1>
28 {contact.first || contact.last ? (
29 <>
30 {contact.first} {contact.last}
31 </>
32 ) : (
33 <i>No Name</i>
34 )}{" "}
35 <Favorite contact={contact} />
36 </h1>
37
38 {contact.twitter ? (
39 <p>
40 <a
41 href={`https://twitter.com/${contact.twitter}`}
42 >
43 {contact.twitter}
44 </a>
45 </p>
46 ) : null}
47
48 {contact.notes ? <p>{contact.notes}</p> : null}
49
50 <div>
51 <Link to={`/contacts/${contact.documentId}/edit`} className="buttonLink">Edit</Link>
52
53 <Form
54 action="delete"
55 method="post"
56 onSubmit={(event) => {
57 const response = confirm(
58 "Please confirm you want to delete this record."
59 );
60 if (!response) {
61 event.preventDefault();
62 }
63 }}
64 >
65 <button type="submit">Delete</button>
66 </Form>
67 </div>
68 </div>
69 </div>
70 );
71}
72
73const Favorite: FunctionComponent<{
74 contact: Pick<ContactRecord, "favorite">;
75}> = ({ contact }) => {
76 const favorite = contact.favorite;
77
78 return (
79 <Form method="post">
80 <button
81 aria-label={
82 favorite
83 ? "Remove from favorites"
84 : "Add to favorites"
85 }
86 name="favorite"
87 value={favorite ? "false" : "true"}
88 >
89 {favorite ? "★" : "☆"}
90 </button>
91 </Form>
92 );
93};
In the code above,
contact
variable.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.
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
.
1import {
2 Form,
3 Link,
4 Links,
5 Meta,
6 Outlet,
7 Scripts,
8 ScrollRestoration,
9 useLoaderData,
10} from "@remix-run/react";
11import { type LinksFunction } from "@remix-run/node";
12import appStyleHref from './app.css'
13import { getContacts } from "./data";
14export const links: LinksFunction = () => [
15 { rel:"stylesheet", href: appStyleHref }
16]
17
18export const loader = async () => {
19 const contacts = await getContacts();
20 return { contacts }
21};
22
23export default function App() {
24 const { contacts } = useLoaderData<typeof loader>();
25 return (
26 <html lang="en">
27 <head>
28 <meta charSet="utf-8" />
29 <meta name="viewport" content="width=device-width, initial-scale=1" />
30 <Meta />
31 <Links />
32 </head>
33 <body>
34 <div id="sidebar">
35 <h1>Remix Contacts</h1>
36 <div>
37 <Form id="search-form" role="search">
38 <input
39 id="q"
40 aria-label="Search contacts"
41 placeholder="Search"
42 type="search"
43 name="q"
44 />
45 <div id="search-spinner" aria-hidden hidden={true} />
46 </Form>
47 <Form method="post">
48 <button type="submit">New</button>
49 </Form>
50 </div>
51 <nav>
52 {contacts.length ? (
53 <ul>
54 {contacts.map((contact) => (
55 <li key={contact.id}>
56 <Link to={`contacts/${contact.id}`}>
57 {contact.first || contact.last ? (
58 <>
59 {contact.first} {contact.last}
60 </>
61 ) : (
62 <i>No Name</i>
63 )}{" "}
64 {contact.favorite ? (
65 <span>★</span>
66 ) : null}
67 </Link>
68 </li>
69 ))}
70 </ul>
71 ) : (
72 <p>
73 <i>No contacts</i>
74 </p>
75 )}
76 </nav>
77 </div>
78
79 <div id="detail">
80 <Outlet />
81 </div>
82
83 <ScrollRestoration />
84 <Scripts />
85 </body>
86 </html>
87 );
88}
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.
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
1import { Form, useLoaderData } from "@remix-run/react";
2import type { FunctionComponent } from "react";
3
4import { getContact, type ContactRecord } from "../data";
5import type { LoaderFunctionArgs } from "@remix-run/node";
6import invariant from "tiny-invariant";
7
8export const loader = async ({
9 params,
10}: LoaderFunctionArgs) => {
11 invariant(params.contactId, "Missing contactId param");
12 const contact = await getContact(params.contactId);
13 if (!contact) {
14 throw new Response("Not Found", { status: 404 });
15 }
16 return { contact };
17};
18
19export default function Contact() {
20 const { contact } = useLoaderData<typeof loader>();
21 return (
22 <div id="contact">
23 ...
24 </div>
25 )
26}
In the code above:
{ 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. 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.useLoaderData
. There are no changes to the component markup. 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.
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
In the terminal prompt above:
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.
Once registered, you will be logged into the Strapi dashboard.
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:
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.
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.
1type ContactMutation = {
2 id?: string;
3 first?: string;
4 last?: string;
5 avatar?: string;
6 twitter?: string;
7 notes?: string;
8 favorite?: boolean;
9};
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.
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.
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.
1{
2 "data": [
3 {
4 "id": 2,
5 "documentId": "dva2ab16hv3bzq9s5onbxgvb",
6 "first": "Shruti",
7 "last": "Kapoor",
8 "avatar": "https://sessionize.com/image/124e-400o400o2-wHVdAuNaxi8KJrgtN3ZKci.jpg",
9 "twitter": "@shrutikapoor08",
10 "notes": null,
11 "favorite": null,
12 "createdAt": "2024-12-12T08:21:21.633Z",
13 "updatedAt": "2024-12-12T08:21:21.633Z",
14 "publishedAt": "2024-12-12T08:21:21.657Z"
15 },
16 {
17 "id": 4,
18 "documentId": "y1s5z2oqmmppu1d9n7wmelc9",
19 "first": "Ryan",
20 "last": "Florence",
21 "avatar": "https://sessionize.com/image/9273-400o400o2-3tyrUE3HjsCHJLU5aUJCja.jpg",
22 "twitter": null,
23 "notes": null,
24 "favorite": true,
25 "createdAt": "2024-12-12T08:21:41.582Z",
26 "updatedAt": "2024-12-12T08:21:41.582Z",
27 "publishedAt": "2024-12-12T08:21:41.587Z"
28 },
29 {
30 "id": 6,
31 "documentId": "fnpqe7u1pm70bkcx6yjzahq7",
32 "first": "Oscar",
33 "last": "Newman",
34 "avatar": "https://sessionize.com/image/d14d-400o400o2-pyB229HyFPCnUcZhHf3kWS.png",
35 "twitter": "@__oscarnewman",
36 "notes": null,
37 "favorite": null,
38 "createdAt": "2024-12-12T08:22:03.956Z",
39 "updatedAt": "2024-12-12T08:22:03.956Z",
40 "publishedAt": "2024-12-12T08:22:03.961Z"
41 },
42 {
43 "id": 11,
44 "documentId": "n6yoo2mil4a36kpia4qy2x03",
45 "first": "Muhammad",
46 "last": "Syakirurohman",
47 "avatar": "https://devaradise.com/_astro/syakir.JYhBotXK_1ltmJh.webp",
48 "twitter": "@syakirurohman",
49 "notes": null,
50 "favorite": true,
51 "createdAt": "2024-12-13T23:47:36.654Z",
52 "updatedAt": "2024-12-13T23:58:48.752Z",
53 "publishedAt": "2024-12-13T23:58:48.758Z"
54 }
55 ],
56 "meta": {
57 "pagination": {
58 "page": 1,
59 "pageSize": 25,
60 "pageCount": 1,
61 "total": 4
62 }
63 }
64}
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.
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.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.
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.
1type ContactMutation = {
2 id?: string;
3 documentId?: string;
4 first?: string;
5 last?: string;
6 avatar?: string;
7 twitter?: string;
8 notes?: string;
9 favorite?: boolean;
10};
11
12export type ContactRecord = ContactMutation & {
13 id: string;
14 createdAt: string;
15};
16
17const STRAPI_BASE_URL = process.env.STRAPI_BASE_URL || 'http://localhost:1337'
18
19export async function getContacts(query?: string | null) {
20}
21
22export async function createEmptyContact() {
23}
24
25export async function getContact(id: string) {
26}
27
28export async function updateContact(id: string, updates: ContactMutation) {
29}
30
31export async function deleteContact(id: string) {
32}
To fetch all contacts from Strapi, let's redefine the getContacts
function.
1export async function getContacts(query?: string | null) {
2 try {
3 const response = await fetch(STRAPI_BASE_URL + "/api/contacts")
4 const json = await response.json()
5 return json.data as ContactMutation[]
6 } catch (err) {
7 console.log(err)
8 }
9}
After that, go to root.tsx
. Make sure you change the import source for getContacts
function.
1import { getContacts } from "./data.server";
Save and reload your app homepage. You will see that now it fetch the contacts from Strapi.
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}
1...
2<nav>
3 {contacts?.length ? (
4 <ul>
5 {contacts.map((contact) => (
6 <li key={contact.id}>
7 <Link to={`contacts/${contact.documentId}`}>
8 {contact.first || contact.last ? (
9 <>
10 {contact.first} {contact.last}
11 </>
12 ) : (
13 <i>No Name</i>
14 )}{" "}
15 {contact.favorite ? (
16 <span>★</span>
17 ) : null}
18 </Link>
19 </li>
20 ))}
21 </ul>
22 ) : (
23 <p>
24 <i>No contacts</i>
25 </p>
26 )}
27</nav>
28...
This will change the contactId
param in contact detail page.
Navigate to data.server.ts
and redefine the getContact
function as follows
1export async function getContact(documentId: string) {
2 try {
3 const response = await fetch(STRAPI_BASE_URL + "/api/contacts/" + documentId);
4 const json = await response.json()
5 return json.data
6 } catch (err) {
7 console.log(err)
8 }
9}
In ./routes/contacts.$contactId.tsx
, make sure you change the import source for getContact
function to ./data.server
.
1import { getContacts } from "./data.server";
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.
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:
1...
2<Form method="post">
3 <button type="submit">New</button>
4</Form>
5...
Change it to
1<Link to="contacts/create" className="buttonLink">Create</Link>
Add this CSS style to app.css
to style the Create
link
1.buttonLink {
2 font-size: 1rem;
3 font-family: inherit;
4 border: none;
5 border-radius: 8px;
6 padding: 0.5rem 0.75rem;
7 box-shadow: 0 0px 1px hsla(0, 0%, 0%, 0.2), 0 1px 2px hsla(0, 0%, 0%, 0.2);
8 background-color: white;
9 line-height: 1.5;
10 margin: 0;
11 color: #3992ff;
12 font-weight: 500;
13 text-decoration: none;
14}
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.
1import { useNavigate, Form } from "@remix-run/react";
2
3export default function CreateContact() {
4 const navigate = useNavigate();
5
6 return (
7 <Form method="post">
8 <div className="create-form-grid">
9 <FormInput
10 aria-label="First name"
11 name="first"
12 type="text"
13 label="First name"
14 placeholder="First"
15 />
16 <FormInput
17 aria-label="Last name"
18 name="last"
19 type="text"
20 label="Last name"
21 placeholder="Last"
22 />
23 <FormInput
24 name="twitter"
25 type="text"
26 label="Twitter"
27 placeholder="@jack"
28 />
29 <FormInput
30 aria-label="Avatar URL"
31 name="avatar"
32 type="text"
33 label="Avatar URL"
34 placeholder="https://example.com/avatar.jpg"
35 />
36 </div>
37 <br/>
38 <div className="input-field">
39 <label htmlFor="notes">Notes</label>
40 <textarea id="notes" name="notes" rows={6} />
41 </div>
42
43 <div className="button-group">
44 <button type="submit">Create</button>
45 <button type="button" onClick={() => navigate(-1)}>
46 Cancel
47 </button>
48 </div>
49 </Form>
50 );
51}
52
53function FormInput({
54 type,
55 name,
56 label,
57 placeholder,
58 defaultValue = "",
59 errors,
60}: Readonly<{
61 type: string;
62 name: string;
63 label?: string;
64 placeholder?: string;
65 errors?: Record<string, string[]>;
66 defaultValue?: string;
67}>) {
68 return (
69 <div className="input-field">
70 <div>
71 <label htmlFor={name}>{label}</label>
72 <div>
73 <input
74 name={name}
75 type={type}
76 placeholder={placeholder}
77 defaultValue={defaultValue}
78 />
79 </div>
80 </div>
81 {errors && errors[name] &&
82 <ul>
83 {errors[name].map((error: string) => (
84 <li key={error} className="input-error">
85 {error}
86 </li>
87 ))}
88 </ul>
89 }
90 </div>
91 );
92}
Add this CSS blocks to ./app.css
as well for styling.
1.button-group {
2 margin-top: 1rem;
3 display: flex;
4 gap: 0.5rem;
5}
6
7.input-field input, .input-field textarea {
8 width: 100%;
9 display: block;
10}
11
12.input-error {
13 color: #e53e3e;
14 font-size: 0.875rem;
15 margin-top: 0.25rem;
16}
17
18.create-form-grid {
19 display: grid;
20 grid-template-columns: 1fr 1fr;
21 grid-template-rows: auto auto;
22 gap: 20px;
23}
24
25.root-error {
26 background: red;
27 color: white;
28 padding: 6rem;
29}
30
31.contact-error {
32 padding: 6rem;
33}
Save the filees, and navigate to Create contact page by clicking on Create
button.
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.
1// in ./data.server.ts
2export async function createContact(data: Record<string, unknown>) {
3 try {
4 const response = await fetch(STRAPI_BASE_URL + "/api/contacts", {
5 method: "POST",
6 headers: {
7 "Content-Type": "application/json",
8 },
9 body: JSON.stringify({ data }),
10 });
11 const json = await response.json()
12 return json.data
13 } catch (err) {
14 console.log(err)
15 }
16}
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.
1import { useNavigate, Form } from "@remix-run/react";
2import { type ActionFunctionArgs, redirect } from "@remix-run/node";
3import { createContact } from "./../data.server";
4
5export async function action({ request }: ActionFunctionArgs) {
6 const formData = await request.formData();
7 const data = Object.fromEntries(formData);
8 const newEntry = await createContact(data);
9
10 return redirect("/contacts/" + newEntry.documentId)
11}
12
13export default function CreateContact() {
14 // nothing changed in this block for now
15 //...
16}
In the code above, we added the action
function for this route. Here's how it works:
{ request }
parameter.createContact
function is called to add a new record to the Strapi Contact collection using the submitted form data.redirect
function provided by Remix. Save your changes and reload the app. Try adding new contact and see how it works.
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.
1import { useNavigate, Form } from "@remix-run/react";
2import { type ActionFunctionArgs, redirect } from "@remix-run/node";
3import { createContact } from "./../data.server";
4import * as z from "zod";
5
6export async function action({ request }: ActionFunctionArgs) {
7 const formData = await request.formData();
8 const data = Object.fromEntries(formData);
9
10 const formSchema = z.object({
11 avatar: z.string().url().min(2),
12 first: z.string().min(2),
13 last: z.string().min(2),
14 twitter: z.string().min(2),
15 });
16
17 const validatedFields = formSchema.safeParse({
18 avatar: data.avatar,
19 first: data.first,
20 last: data.last,
21 twitter: data.twitter,
22 });
23
24 if (!validatedFields.success) {
25 return {
26 errors: validatedFields.error.flatten().fieldErrors,
27 message: "Please fill out all missing fields.",
28 data: null,
29 }
30 }
31
32 const newEntry = await createContact(data);
33 return redirect("/contacts/" + newEntry.documentId)
34}
35export default function CreateContact() {
36// Nothing changed yet here
37//...
38}
In the code above:
formSchema
variable, which defines the validation rules for each field in the Create contact form.formSchema.safeParse()
and store the result in the validateFields
variable.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
:
1import { useNavigate, Form, useActionData } from "@remix-run/react";
2import { type ActionFunctionArgs, redirect } from "@remix-run/node";
3import { createContact } from "./../data.server";
4import * as z from "zod";
5
6export async function action({ request }: ActionFunctionArgs) {
7 // nothing changed here
8}
9export default function CreateContact() {
10 const navigate = useNavigate();
11 const formData = useActionData<typeof action>()
12
13 return (
14 <Form method="post">
15 <div className="create-form-grid">
16 <FormInput
17 aria-label="First name"
18 name="first"
19 type="text"
20 label="First name"
21 placeholder="First"
22 errors={formData?.errors}
23 />
24 <FormInput
25 aria-label="Last name"
26 name="last"
27 type="text"
28 label="Last name"
29 placeholder="Last"
30 errors={formData?.errors}
31 />
32 <FormInput
33 name="twitter"
34 type="text"
35 label="Twitter"
36 placeholder="@jack"
37 errors={formData?.errors}
38 />
39 <FormInput
40 aria-label="Avatar URL"
41 name="avatar"
42 type="text"
43 label="Avatar URL"
44 placeholder="https://example.com/avatar.jpg"
45 errors={formData?.errors}
46 />
47 </div>
48 <br/>
49 <div className="input-field">
50 <label htmlFor="notes">Notes</label>
51 <textarea id="notes" name="notes" rows={6} />
52 </div>
53
54 <div className="button-group">
55 <button type="submit">Create</button>
56 <button type="button" onClick={() => navigate(-1)}>
57 Cancel
58 </button>
59 </div>
60 </Form>
61 );
62}
63//...
In the code above:
useActionData
to fetch the form request result and assign it to the formData
variable. useActionData
is imported from @remix-run/react
.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.
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
1import { useNavigate, Form, useActionData, useLoaderData } from "@remix-run/react";
2import { type ActionFunctionArgs, LoaderFunctionArgs, redirect } from "@remix-run/node";
3import { updateContact, getContact } from "./../data.server";
4import * as z from "zod";
5import invariant from "tiny-invariant";
6
7export const loader = async ({
8 params,
9}: LoaderFunctionArgs) => {
10 invariant(params.contactId, "Missing contactId param");
11 const contact = await getContact(params.contactId);
12 if (!contact) {
13 throw new Response("Not Found", { status: 404 });
14 }
15 return { contact };
16};
17export async function action({ params, request }: ActionFunctionArgs) {
18 invariant(params.contactId, "Missing contactId param");
19
20 const formData = await request.formData();
21 const data = Object.fromEntries(formData);
22
23 const formSchema = z.object({
24 avatar: z.string().url().min(2),
25 first: z.string().min(2),
26 last: z.string().min(2),
27 twitter: z.string().min(2),
28 });
29
30 const validatedFields = formSchema.safeParse({
31 avatar: data.avatar,
32 first: data.first,
33 last: data.last,
34 twitter: data.twitter,
35 });
36
37 if (!validatedFields.success) {
38 return {
39 errors: validatedFields.error.flatten().fieldErrors,
40 message: "Please fill out all missing fields.",
41 data: null,
42 }
43 }
44
45 const updatedEntry = await updateContact(params.contactId, data);
46 return redirect("/contacts/" + updatedEntry.documentId)
47}
48export default function EditContact() {
49 const navigate = useNavigate();
50 const { contact } = useLoaderData<typeof loader>();
51 const formData = useActionData<typeof action>();
52
53 return (
54 <Form method="post">
55 <div className="create-form-grid">
56 <FormInput
57 aria-label="First name"
58 name="first"
59 type="text"
60 label="First name"
61 placeholder="First"
62 defaultValue={contact?.first}
63 errors={formData?.errors}
64 />
65 <FormInput
66 aria-label="Last name"
67 name="last"
68 type="text"
69 label="Last name"
70 placeholder="Last"
71 defaultValue={contact?.last}
72 errors={formData?.errors}
73 />
74 <FormInput
75 name="twitter"
76 type="text"
77 label="Twitter"
78 placeholder="@jack"
79 defaultValue={contact?.twitter}
80 errors={formData?.errors}
81 />
82 <FormInput
83 aria-label="Avatar URL"
84 name="avatar"
85 type="text"
86 label="Avatar URL"
87 placeholder="https://example.com/avatar.jpg"
88 defaultValue={contact?.avatar}
89 errors={formData?.errors}
90 />
91 </div>
92 <br/>
93 <div className="input-field">
94 <label htmlFor="notes">Notes</label>
95 <textarea id="notes" name="notes" rows={6} defaultValue={contact?.notes} />
96 </div>
97
98 <div className="button-group">
99 <button type="submit">Update</button>
100 <button type="button" onClick={() => navigate(-1)}>
101 Cancel
102 </button>
103 </div>
104 </Form>
105 );
106}
107
108// nothing change in FormInput component
109function FormInput(){
110 ///...
111}
In the code above:
loader
function to load the existing contact, similar to the one in contacts.$contactId.tsx
. action
function, we included params
in the arguments to fetch params.contactId
. This will be passed to the updateContact
function as an argument. EditContact
. Using useLoaderData
, we accessed the loaded contact data and prefilled the contact fields in <FormInput>
and <textarea>
by setting their defaultValue
attributes. 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:
1//...
2export async function updateContact(documentId: string, updates: ContactMutation) {
3 try {
4 const response = await fetch(STRAPI_BASE_URL + "/api/contacts/" + documentId, {
5 method: "PUT",
6 headers: {
7 "Content-Type": "application/json",
8 },
9 body: JSON.stringify({ data: { ...updates} }),
10 });
11 const json = await response.json();
12 return json.data;
13 } catch (error) {
14 console.log(error);
15 }
16}
17//...
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.
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.
1<Form
2 action="delete"
3 method="post"
4 onSubmit={(event) => {
5 const response = confirm(
6 "Please confirm you want to delete this record."
7 );
8 if (!response) {
9 event.preventDefault();
10 }
11 }}
12 >
13 <button type="submit">Delete</button>
14</Form>
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
.
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.
1import { type ActionFunctionArgs, redirect } from "@remix-run/node";
2import invariant from "tiny-invariant";
3import { deleteContact } from "../data.server";
4
5export const action = async ({ params } : ActionFunctionArgs ) => {
6 invariant(params.contactId, "Missing contactId param");
7 await deleteContact(params.contactId);
8 return redirect("/contacts")
9}
In the code above:
params.contactId
with invariant, as we also do in contact detail page and update contact action.deleteContact
from ./data.server
to execute the delete contact functionality/contacts
In the ./data.server.ts
, we also need to update the deleteContact
function as follows:
1//...
2export async function deleteContact(documentId: string) {
3 try {
4 const response = await fetch(STRAPI_BASE_URL + "/api/contacts/" + documentId, {
5 method: "DELETE",
6 });
7 const json = await response.json();
8 return json.data;
9 } catch (error) {
10 console.log(error);
11 }
12}
13//...
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.
You'll notice it shows a 404 Not Found
error because we don't have a route page for /contacts
.
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:
1export default function Contacts() {
2 return (
3 <div>This will show up when no items are selected</div>
4 )
5}
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:
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.
1export function ErrorBoundary() {
2 const error = useRouteError();
3 return (
4 <html lang="en">
5 <head>
6 <title>Oh no!</title>
7 <Meta />
8 <Links />
9 </head>
10 <body>
11 <div style={{ padding: '3rem'}}>
12 <h2>Oh no! Something went wrong</h2>
13 {
14 isRouteErrorResponse(error) && <div>
15 <div><strong>{error.status} Error</strong></div>
16 <div>{error.data}</div>
17 <br/>
18 <Link to="/" className="buttonLink">Go back to home</Link>
19 </div>
20 }
21 </div>
22 <Scripts />
23 </body>
24 </html>
25 );
26}
27
28export default function App() {
29// nothing changed here
30}
Dont forget to add ErrorBoundary
and isRouteErrorResponse
to the import code.
1import {
2 Form,
3 isRouteErrorResponse,
4 Link,
5 Links,
6 Meta,
7 Outlet,
8 Scripts,
9 ScrollRestoration,
10 useLoaderData,
11 useRouteError,
12} from "@remix-run/react";
13
14//...
Now, save the file and try accessing the app with a random URL. The custom error boundary we just implemented will be displayed.
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
1export function ErrorBoundary() {
2 const error = useRouteError();
3 return (
4 <div style={{ padding: '3rem'}}>
5 <h2>Oh no! Something went wrong</h2>
6 {
7 isRouteErrorResponse(error) && <div>
8 <div><strong>{error.status} Error</strong></div>
9 <div>{error.data}</div>
10 <br/>
11 <Link to="/" className="buttonLink">Go back to home</Link>
12 </div>
13 }
14 </div>
15 );
16}
Dont forget to import ErrorBoundary
and isRouteErrorResponse
from @remix-run/react
as well, so the updated import code is as follows:
1import { Form, isRouteErrorResponse, Link, useLoaderData, useRouteError } from "@remix-run/react";
In the loader
function in contact.$contactId.tsx
, there is an error thrown when the contact is not found in the Strapi API.
1//...
2export const loader = async ({
3 params,
4}: LoaderFunctionArgs) => {
5 invariant(params.contactId, "Missing contactId param");
6 const contact = await getContact(params.contactId);
7 if (!contact) {
8 throw new Response("Not Found", { status: 404});
9 }
10 return { contact };
11};
12//...
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:
In the contact detail page, contact.$contactId.tsx
, we have already included a <Favorite>
component.
1const Favorite: FunctionComponent<{
2 contact: Pick<ContactRecord, "favorite">;
3}> = ({ contact }) => {
4 const favorite = contact.favorite;
5
6 return (
7 <Form method="post">
8 <button
9 aria-label={
10 favorite
11 ? "Remove from favorites"
12 : "Add to favorites"
13 }
14 name="favorite"
15 value={favorite ? "false" : "true"}
16 >
17 {favorite ? "★" : "☆"}
18 </button>
19 </Form>
20 );
21};
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.
1//...
2export async function action({ params, request} : ActionFunctionArgs ) {
3 invariant(params.contactId, "Missing contactId param");
4 const formData = await request.formData();
5 return updateContact(params.contactId, {
6 favorite: formData.get("favorite") === "true"
7 })
8}
9
10//...
Dont forget to also import ActionFunctionArgs
and updateContact
.
1// other imports
2// ...
3
4import { getContact, updateContact, type ContactRecord } from "../data.server";
5import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
6
7//...
Save your changes, and try to favorite or unfavorite the contact. You will see that now you can star or unstar a contact.
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.
1{
2 "data": [
3 {
4 "id": 34,
5 "documentId": "y1s5z2oqmmppu1d9n7wmelc9",
6 "first": "Ryan",
7 "last": "Florence",
8 "avatar": "https://sessionize.com/image/9273-400o400o2-3tyrUE3HjsCHJLU5aUJCja.jpg",
9 "twitter": null,
10 "notes": null,
11 "favorite": false,
12 "createdAt": "2024-12-12T08:21:41.582Z",
13 "updatedAt": "2024-12-18T12:50:16.890Z",
14 "publishedAt": "2024-12-18T12:50:16.896Z"
15 },
16 // ...other contacts
17 ],
18 "meta": {
19 "pagination": {
20 "page": 1,
21 "pageSize": 25,
22 "pageCount": 1,
23 "total": 4
24 }
25 }
26}
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:
1/api/contacts?sort[0]=createdAt:desc&filters[first][$contains]=syakir&filters[last][$contains]=syakir&pagination[pageSize]=50&pagination[page]=1
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
Go to ./data.server.ts
. In the top of file, import qs
.
1import qs from "qs"
Then, update getContacts
function.
1//...
2export async function getContacts(q?: string | null) {
3 const query = qs.stringify({
4 sort: 'createdAt:desc',
5 filters: {
6 $or: [
7 { first: { $contains: q }},
8 { last: { $contains: q }},
9 { twitter: { $contains: q }},
10 ]
11 },
12 pagination: {
13 pageSize: 50,
14 page: 1,
15 },
16 })
17
18 try {
19 const response = await fetch(STRAPI_BASE_URL + "/api/contacts?" + query)
20 const json = await response.json()
21 return json.data as ContactMutation[]
22 } catch (err) {
23 console.log(err)
24 }
25}
26//...
In the code above, we implemented a query builder to:
createdAt
column in descending order.first
, last
, and twitter
fields. 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.
1import {
2 Form,
3 isRouteErrorResponse,
4 Link,
5 Links,
6 Meta,
7 Outlet,
8 Scripts,
9 ScrollRestoration,
10 useLoaderData,
11 useRouteError,
12 useSubmit,
13} from "@remix-run/react";
14import { LoaderFunctionArgs, type LinksFunction } from "@remix-run/node";
15import appStyleHref from './app.css?url'
16import { getContacts } from "./data.server";
17export const links: LinksFunction = () => [
18 { rel:"stylesheet", href: appStyleHref }
19]
20
21export const loader = async ({ request }: LoaderFunctionArgs) => {
22 const url = new URL(request.url);
23 const q = url.searchParams.get("q");
24 const contacts = await getContacts(q);
25 return { contacts, q }
26};
27
28export function ErrorBoundary() {
29 // nothing changed in error boundary
30}
31
32export default function App() {
33 const { contacts, q } = useLoaderData<typeof loader>();
34 const submit = useSubmit();
35
36 return (
37 <html lang="en">
38 <head>
39 <meta charSet="utf-8" />
40 <meta name="viewport" content="width=device-width, initial-scale=1" />
41 <Meta />
42 <Links />
43 </head>
44 <body>
45 <div id="sidebar">
46 <h1>Remix Contacts</h1>
47 <div>
48 <Form id="search-form" role="search" onChange={(e) => submit(e.currentTarget)}>
49 <input
50 id="q"
51 aria-label="Search contacts"
52 placeholder="Search"
53 type="search"
54 name="q"
55 defaultValue={q || ''}
56 />
57 <div id="search-spinner" aria-hidden hidden={true} />
58 </Form>
59 // nothing changed after this code
60 </div>
61 //... other codes
62 </body>
63 </html>
64 );
65}
In the code above:
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.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.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!
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
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.
1// add new import `useNavigation`
2import {
3 Form,
4 isRouteErrorResponse,
5 Link,
6 Links,
7 Meta,
8 Outlet,
9 Scripts,
10 ScrollRestoration,
11 useLoaderData,
12 useNavigation,
13 useRouteError,
14 useSubmit,
15} from "@remix-run/react";
16//.. other imports
17
18export const loader = async ({ request }: LoaderFunctionArgs) => {
19 // nothing changed here
20}
21
22export function ErrorBoundary() {
23 // nothing changed here
24}
25
26export default function App() {
27 const { contacts, q } = useLoaderData<typeof loader>();
28 const submit = useSubmit();
29 const navigation = useNavigation();
30 const searching = navigation.location && new URLSearchParams(navigation.location.search).has("q");
31
32 return (
33 <html lang="en">
34 <head>
35 <meta charSet="utf-8" />
36 <meta name="viewport" content="width=device-width, initial-scale=1" />
37 <Meta />
38 <Links />
39 </head>
40 <body>
41 <div id="sidebar">
42 <h1>Remix Contacts</h1>
43 <div>
44 <Form id="search-form" role="search" onChange={(e) => {
45 const isFirstSearch = q === null;
46 submit(e.currentTarget, {
47 replace: !isFirstSearch,
48 });
49 }}>
50 <input
51 id="q"
52 className={searching ? 'loading' : ''}
53 aria-label="Search contacts"
54 placeholder="Search"
55 type="search"
56 name="q"
57 defaultValue={q || ''}
58 />
59 <div id="search-spinner" aria-hidden hidden={!searching} />
60 </Form>
61 <Link to="contacts/create" className="buttonLink">Create</Link>
62 </div>
63 //... other codes, nothing changed here
64 </div>
65 //... other codes, nothing changed here
66 </body>
67 </html>
68 );
69}
In the code above:
useNavigation
was imported to access the navigation state, which will be used to evaluate a loading state.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. 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. 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.
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
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:
onChange
function to implement a JavaScript debounce mechanism.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:
active
and pending
states.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:
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:
catch
block in the function located in ./data/server
. Include the Strapi error message in your error object.Link:
You can find complete project codes in this link: Github repository.
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
Syakir (Sha·keer) is an experienced Front-end Engineer who is actively writing articles and tutorials about Frontend development on devaradise.com & dev.to. He is also working on Paradise UI, an open source React UI library.