Table of contents
Open Table of contents
- The Goal
- Executing the Rebuild
- ”The root route”
- ”Adding stylesheets with Links”
- ”The Contact Route UI”
- “Nested Routes and Layouts”
- “Client Side Routing”
- ”Loading Data”
- “Type Interference”
- ”URL Params in Loaders”
- “Validating Params and Throwing Responses”
- ”Data Mutations”
- ”Creating Contacts”
- ”Updating Data”
- ”Updating Contacts with FormData”
- ”Redirecting new records to the edit page”
- “Active Link Styling”
- “Global Pending UI”
- ”Deleting Records”
- “Index Routes”
- ”Cancel Button”
- “URLSearchParams and GET Submissions”
- ”Synchronizing URLs to Form State”
- “Submitting Form’s onChange”
- “Adding Search Spinner”
- “Managing the History Stack”
- “Forms Without Navigation”
- “Optimistic UI”
- Discussing the Two Approaches
- Conclusion
The Goal
- Replicate the Remix Contacts tutorial in Next.js 14 using React Server Components, Server Actions, Transitions and Prisma while maintaining progressive enhancement.
The Remix Contacts tutorial is Remix’s official tutorial. It is a simple CRUD app that allows you to create, read, update and delete contacts. Remix focuses on web standards, and therefore the app is naturally progressively enhanced. With the introduction of React Server Components, I wanted to see how the tutorial would look in Next.js, and see if I could replicate it while maintaining progressive enhancement.
This app also replaces the Remix stylesheet with tailwind.
NB! See my Remix Contacts Rebuild V2 GitHub repository for a “better”, more Next.js-like approach of rebuilding the Remix Contacts tutorial. That version also has additional improvements.
Executing the Rebuild
Please view the GitHub repo for the full code. These steps are more to give an overview of the process and discuss the decisions made.
Refer to the Remix tutorial for each step of the process.
”The root route”
Starting from a fresh Next.js project, we paste the code from the Remix Contacts tutorial into the root layout component, since this is a sidebar that will be present on all pages.
”Adding stylesheets with Links”
We add the css to the globals.css. The repo contains the tailwind version.
”The Contact Route UI”
We make a new folder called contacts/
and added a dynamic route subfolder [contactId]/
containing a page.tsx
for the contact route. We copied the code from the Remix tutorial into this file. However, I’ll extract the favorite component into a separate file inside a folder called components/
. In addition, we’ll pass the contact as a prop to the favorite component using the DB schema:
// components/Favorite.tsx
export default function Favorite({ contact }: { contact: 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>
);
}
We’re also getting errors because I’m using onSubmit
on the form, which is not allowed in server components. I’ll split the form into a separate component with "use client"
, DeleteContactButton.tsx
.
In addition, we’ll replace the <form action="edit">
with a Next.js Link
component to the edit page (and update styling):
<Link href={`/contacts/${contact.id}/edit`}>Edit</Link>
“Nested Routes and Layouts”
We just need to add the children
prop from the root layout inside the jsx:
<div id="detail">{children}</div>
“Client Side Routing”
Let’s just replace the relevant <a>
tags with Next.js Link
components.
”Loading Data”
We will not be using route loaders, but instead fetch the data inside the root layout by making it async and calling a database query through a file in a new data access layer, lib/services/getContacts.ts
, and add the tutorial code to the layout component. The database query will be a simple Prisma query, and we mark the file with "server-only"
to avoid it being called from client code:
// app/layout.tsx
export default async function RootLayout({ children }: Props) {
const contacts = await getContacts();
// lib/services/getContacts.ts
import "server-only";
import { prisma } from "../../db";
export async function getContacts() {
return prisma.contact.findMany();
}
“Type Interference”
We will get type interference automatically.
”URL Params in Loaders”
We don’t have to use loaders, we can just make another server function in the data access layer, lib/services/getContact.ts
, and call it from the contact route with the params from the Nextjs page properties:
// app/contacts/[contactId]/page.tsx
type PageProps = {
params: {
contactId: string;
};
};
export default async function ContactPage({ params }: PageProps) {
const contact = await getContact(params.contactId);
// lib/services/getContact.ts
import "server-only";
import invariant from "tiny-invariant";
import { prisma } from "../../db";
export async function getContact(contactId: string) {
const contact = await prisma.contact.findUnique({
where: {
id: contactId,
},
});
return contact;
}
“Validating Params and Throwing Responses”
We can just use the notFound
function from Next.js to throw the correct error if the contact is not found, and create an error boundary with [contactId]/not-found.tsx
to catch the error and display a 404 page.
// lib/services/getContact.ts
import { notFound } from "next/navigation";
import invariant from "tiny-invariant";
import { prisma } from "../../db";
export async function getContact(contactId: string) {
invariant(contactId, "Missing contactId param");
const contact = await prisma.contact.findUnique({
where: {
id: contactId,
},
});
if (!contact) {
notFound();
}
return contact;
}
”Data Mutations”
We will be following Remix’s approach to data mutations, which is to use a form for mutations to ensure progressive enhancement. However, in Remix the action for the route is automatically called for a form submit, but we will define which function to call per form. We will be binding to React’s modified <form>
element’s action
property to call the server action when the form is submitted.
”Creating Contacts”
We will create a new file in the data access layer, lib/actions/createEmptyContact.ts
, and bind it to the form in the root layout:
// lib/actions/createEmptyContact.ts
"use server";
import { revalidatePath } from "next/cache";
import { prisma } from "../../db";
export async function createEmptyContact() {
await prisma.contact.create({
data: {},
});
revalidatePath("/");
}
<form action={createEmptyContact}>
<button type="submit">New</button>
</form>
The createEmptyContact function will be called when the form is submitted, and the contact will be created in the database. We also have to revalidate the root path to update the contacts list, unlike in Remix where this is done automatically.
”Updating Data”
We will create a new subfolder with a new page, contacts/[contactId]/edit/page.ts
to handle the edit page. We will just get the contact from the database like we did in the contact page, and paste the tutorial code into the page.
”Updating Contacts with FormData”
We will create a new file in the data access layer, lib/actions/updateContact.ts
, and bind it to the form in the edit page.
However, we have a problem. We don’t receive route params in the server action (unlike the action function in Remix), so we have to pass it to the action from the page. To do this without breaking the progressive enhancement, we could either create a hidden form field, or make a copy of updateContact
with the contactId
as initial arguments by using .bind
. We will go with the latter:
// app/contacts/[contactId]/edit/page.tsx
export default async function EditContactPage({ params: { contactId } }: PageProps) {
const contact = await getContact(contactId);
const updateContactById = updateContact.bind(null, contact.id);
return (
<form key={contact.id} id="contact-form" action={updateContactById}>
// lib/actions/updateContact.ts
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import invariant from "tiny-invariant";
import { prisma } from "../../db";
export async function updateContact(contactId: string, formData: FormData) {
invariant(contactId, "Missing contactId param");
const updates = Object.fromEntries(formData);
await prisma.contact.update({
data: updates,
where: {
id: contactId,
},
});
revalidatePath(`/contacts/${contactId}`);
redirect(`/contacts/${contactId}`);
}
The formData is also passed to the server action, and the contact is updated in the database. Again, we have to revalidate the path, then use Next’s redirect
to the navigate to the contact page after the contact has been updated. See the Remix tutorial for more information on the formData object handling.
”Redirecting new records to the edit page”
We will just redirect to the edit page for the created contacts after creating it in the database:
// lib/actions/createEmptyContact.ts
"use server";
export async function createEmptyContact() {
const contact = await prisma.contact.create({
data: {},
});
revalidatePath("/");
redirect(`/contacts/${contact.id}/edit`);
}
“Active Link Styling”
Next.js does not have a NavLink
component like remix. We have to make a new client component, ContactButton.tsx
, that uses the usePathname
hook (which requires client-side js) to check if the current path matches the contact id, and then apply the active styling.
In addition, we have to use a transition to track the state of the page that is loading. We add an onClick
handler to the Link
component in addition to the href
prop, prevent the default behavior and call the router.push
function inside a transition:
// components/ContactButton.tsx
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import React, { useTransition } from "react";
import type { Contact } from "@prisma/client";
export default function ContactButton({ contact }: { contact: Contact }) {
const pathName = usePathname();
const isActive = pathName.includes(`/contacts/${contact.id}`);
const [isPending, startTransition] = useTransition();
const router = useRouter();
return (
<Link
className={isActive ? "active" : isPending ? "pending" : ""}
href={`/contacts/${contact.id}`}
onClick={e => {
e.preventDefault();
startTransition(() => {
router.push(`/contacts/${contact.id}`);
});
}}
>
{contact.first || contact.last ? (
<>
{contact.first} {contact.last}
</>
) : (
<i>No Name</i>
)}{" "}
{contact.favorite ? <span>★</span> : null}
</Link>
);
}
“Global Pending UI”
This is where it gets interesting. We want to fade the main page whenever the app is in a loading state, and we know that different buttons across the app can trigger this state.
In remix, we would use the useNavigation()
hook. However, Next.js does not expose router events for composition reasons, and expects you to use transitions instead.
I found the article Global progress in Next.js by Sam Selikoff and Ryan Toronto on how to create a global progress bar very helpful here - please refer to it for more information.
I tested out a simpler approach using the group-has-
CSS and adding a data-pending
to the component firing a global pending state as mentioned in the article Instant Search Params with React Server Components. However I don’t want to add this logic to every component that can trigger a loading state.
Instead, let’s create a global pending state provider that can trigger a common transition for all components inside providers/LoadingProvider.tsx
:
// providers/LoadingProvider.tsx
"use client";
import React, { createContext, useTransition } from "react";
type LoadingContextType = {
isLoading: boolean;
startTransition: (_action: () => void) => void;
};
export const LoadingContext = createContext<LoadingContextType | undefined>(
undefined
);
export default function LoadingProvider({
children,
}: {
children: React.ReactNode;
}) {
const [isPending, startTransition] = useTransition();
const start = (action: () => void) => {
startTransition(() => {
action();
});
};
return (
<LoadingContext.Provider
value={{ isLoading: isPending, startTransition: start }}
>
{children}
</LoadingContext.Provider>
);
}
export function useLoading() {
const context = React.useContext(LoadingContext);
if (context === undefined) {
throw new Error("useLoading must be used within a LoadingProvider");
}
return context;
}
We’ll wrap the app with the LoadingProvider
in layout.tsx
, and create a new client component Details
to fade the detail view when the app is in a loading state:
// components/Details.tsx
"use client";
import { useLoading } from "../providers/LoadingProvider";
import { cn } from "../utils/style";
export const Details = ({ children }: { children: React.ReactNode }) => {
const { isLoading } = useLoading();
return <div className={isLoading ? "animate-pulse" : ""}>{children}</div>;
};
Note, this will still render server components since they are slotted as children of the Details
component.
However, to use this we now need to make all the components that can trigger a loading state a progressively enhanced client component that can use the useLoading
hook. This is a lot of boilerplate code. Let’s make some abstractions:
// components/NavButton.tsx
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import React from "react";
import { useLoading } from "../providers/LoadingProvider";
type Props = {
children: React.ReactNode;
href: string;
};
export default function NavButton({ children, href }: Props) {
const router = useRouter();
const { startTransition } = useLoading();
return (
<Link
href={href}
onClick={e => {
e.preventDefault();
startTransition(() => {
router.push(href);
});
}}
>
{children}
</Link>
);
}
// components/ActionButton.tsx
"use client";
import React from "react";
import { useLoading } from "../providers/LoadingProvider";
type Props = {
action: () => void;
onClick?: () => void;
children: React.ReactNode;
};
export default function ActionButton({ action, onClick, children }: Props) {
const { startTransition } = useLoading();
return (
<form
action={action}
onSubmit={e => {
e.preventDefault();
startTransition(() => {
onClick ? onClick() : action();
});
}}
>
<button type="submit">{children}</button>
</form>
);
}
Then we’ll just use these components in place of the Link
and form
components in the layout and contact components:
// app/layout.tsx
<ActionButton action={createEmptyContact}>
New
</ActionButton>
// contacts/[contactId]/page.tsx
<NavButton href={`/contacts/${contactId}/edit`}>Edit</NavButton>
You get the idea. We also add the ActionButton
inside the DeleteContactButton
component, and extract the contact form into a separate client component ContactForm
to add logic here as well:
// components/ContactForm.tsx
'use client';
import React from 'react';
import { deleteContact } from '../lib/actions/deleteContact';
import ActionButton from './ActionButton';
export default function ContactForm({ contact }: { contact: Contact }) {
const updateContactById = updateContact.bind(null, contact.id);
const { startTransition } = useLoading();
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
startTransition(async () => {
await updateContactById(new FormData(event.currentTarget));
});
};
return (
<form action={updateContactById} onSubmit={onSubmit} key={contact.id}>
Here we also use the bind
function to pass the contact id to the server action, and replicate the loading state logic.
”Deleting Records”
Phew, let’s get to something easier. Instead of creating separate routes for specific form operations like in the Remix example, server actions let’s us easily call a delete function in the data access layer, lib/actions/deleteContact.ts
, and bind it to the delete button in the contact component:
// lib/actions/deleteContact.ts
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import invariant from "tiny-invariant";
import { prisma } from "../../db";
export async function deleteContact(contactId: string) {
invariant(contactId, "Missing contactId param");
await prisma.contact.delete({
where: {
id: contactId,
},
});
revalidatePath("/");
redirect("/");
}
// components/DeleteContactButton.tsx
"use client";
import React from "react";
import { deleteContact } from "../lib/actions/deleteContact";
import ActionButton from "./ActionButton";
export default function DeleteContactButton({
contactId,
}: {
contactId: string;
}) {
const deleteContactById = deleteContact.bind(null, contactId);
return (
<ActionButton
onClick={() => {
const response = confirm(
"Please confirm you want to delete this record."
);
if (!response) {
return;
}
deleteContactById();
}}
action={deleteContactById}
className="text-red-400"
>
Delete
</ActionButton>
);
}
“Index Routes”
We will just add content to the root page, app/page.tsx
, with the same code as in the Remix tutorial.
”Cancel Button”
We already made an abstraction for navigating and tracking the state, so we can just use the NavButton
component for the cancel button in the edit page:
// contacts/[contactId]/edit/page.tsx
<NavButton href={`/contacts/${contact.id}`}>Cancel</NavButton>
“URLSearchParams and GET Submissions”
In Next.js, search params are not being passed to layout components, so the search params must be fetched from the client-side hook. I made a ContactList
client component to filter the data based on the search params:
// components/ContactList.tsx
"use client";
import { matchSorter } from "match-sorter";
import { useSearchParams } from "next/navigation";
import React from "react";
import ContactButton from "./ContactButton";
import type { Contact } from "@prisma/client";
export default function ContactList({ contacts }: { contacts: Contact[] }) {
const searchParams = useSearchParams();
const query = searchParams.get("q") || "";
const filteredContacts = query
? matchSorter(contacts, query, {
keys: ["first", "last"],
})
: contacts;
return (
<nav className="flex-1 overflow-auto px-8 pt-4">
{filteredContacts.length ? (
<ul>
{filteredContacts.map(contact => {
return (
<li key={contact.id} className="mx-1">
<ContactButton contact={contact} />
</li>
);
})}
</ul>
) : (
<p>
<i>No contacts</i>
</p>
)}
</nav>
);
}
This is not the behavior of the Remix tutorial. I would prefer to use the search params in the layout and get contacts based on the query. At least our search is now quicker.
I’m not entirely sure why this is working without JavaScript. I think it might be because the useSearchParams
hook behaves as URLSearchParams on the server. I could be completely wrong here, though. Please let me know if you have any insights.
TODO: Try this fix
”Synchronizing URLs to Form State”
We will extract the search form into a new Search
client component to use the useSearchParams()
to sync the default value of the input with the search params:
// components/Search.tsx
"use client";
import { useSearchParams } from "next/navigation";
import React from "react";
export default function Search() {
const searchParams = useSearchParams();
const query = searchParams.get("q") || "";
return (
<form role="search">
<input
defaultValue={query}
aria-label="Search contacts"
name="q"
placeholder="Search"
type="search"
/>
<div
aria-hidden
className="search-spinner absolute left-10 top-7 h-4 w-4 animate-spin"
/>
</form>
);
}
“Submitting Form’s onChange”
We will just use an onChange and push the new search params to the router:
// components/Search.tsx
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import React from "react";
export default function Search() {
const router = useRouter();
const searchParams = useSearchParams();
const query = searchParams.get("q") || "";
return (
<form role="search">
<input
onChange={e => {
router.push(`?q=${e.target.value}`);
}}
defaultValue={query}
aria-label="Search contacts"
name="q"
placeholder="Search"
type="search"
/>
</form>
);
}
“Adding Search Spinner”
We will bring in useTransition to track the state of router, and add a spinner to the search component:
// components/Search.tsx
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import React, { useTransition } from "react";
import { cn } from "../utils/style";
export default function Search() {
const router = useRouter();
const pathName = usePathname();
const searchParams = useSearchParams();
const query = searchParams.get('q') || '';
const [searching, startTransition] = useTransition();
return (
<form role="search">
<input
className={cn(
!searching && "search-icon",
"w-full pl-8 outline-offset-1"
)}
onChange={e => {
startTransition(() => {
router.push(`$?q=${e.target.value}`)
});
}}
defaultValue={query}
aria-label="Search contacts"
name="q"
placeholder="Search"
type="search"
/>
<div aria-hidden hidden={!searching} className="search-spinner" />
</form>
);
}
In the Remix tutorial, code is written to avoid the main screen from fading out when the search spinner is active. However, since we are not using our global navigation utility, we don’t have to worry about this.
Also, I had to wrap a Suspense component around the ContactList
component in the layout to resolve build errors and follow best practices.
That leaves my app/layout.tsx
containing this:
// app/layout.tsx
<Suspense>
<div>
<Search />
<ActionButton action={createEmptyContact}>New</ActionButton>
</div>
<ContactList contacts={contacts} />
</Suspense>
“Managing the History Stack”
We will just switch between router.push()
and router.replace()
in the search component based on the query:
onChange={e => {
startTransition(() => {
const isFirstSearch = query === null;
isFirstSearch
? router.push(`$?q=${e.target.value}`)
: router.replace(`$?q=${e.target.value}`);
});
}}
“Forms Without Navigation”
Since we’re using server actions on forms we don’t have to worry about the default page refresh behavior of the form element. We’ll make a new server action to update the favorite flag on the contact, lib/actions/updateFavorite.ts
. We’ll are going to use the same pattern of binding to this function with initial contactId
arguments as we did in the updateContact
function to maintain progressive enhancement:
// lib/actions/favoriteContact.ts
"use server";
import { revalidatePath } from "next/cache";
import invariant from "tiny-invariant";
import { prisma } from "../../db";
export async function favoriteContact(contactId: string, isFavorite: boolean) {
invariant(contactId, "Missing contactId param");
await prisma.contact.update({
data: {
favorite: !isFavorite,
},
where: {
id: contactId,
},
});
revalidatePath("/");
}
Then we will use this action in the Favorite
component:
// components/Favorite.tsx
import React from "react";
import { favoriteContact } from "../lib/actions/favoriteContact";
import type { Contact } from "@prisma/client";
export default function Favorite({ contact }: { contact: Contact }) {
const favorite = contact.favorite;
const favoriteContactById = favoriteContact.bind(null, contact.id, favorite);
return (
<form action={favoriteContactById}>
<button
aria-label={favorite ? "Remove from favorites" : "Add to favorites"}
>
{favorite ? "★" : "☆"}
</button>
</form>
);
}
“Optimistic UI”
For this task, we will use the new useOptimistic()
hook. We will create an onSubmit
function that will trigger if JavaScript is loaded. The action property will be used as a fallback. Then we can use the optimistic value to display the star-icon. We also have to prevent the default behavior inside our onSubmit
, and wrap it in a transition (as instructed by the React docs):
// components/Favorite.tsx
"use client";
import React, { useOptimistic, useTransition } from "react";
import { favoriteContact } from "../lib/actions/favoriteContact";
import { cn } from "../utils/style";
import type { Contact } from "@prisma/client";
export default function Favorite({ contact }: { contact: Contact }) {
const favoriteContactById = favoriteContact.bind(
null,
contact.id,
contact.favorite
);
const [optimisticFavorite, addOptimisticFavorite] = useOptimistic(
contact.favorite
);
const [, startTransition] = useTransition();
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
startTransition(async () => {
addOptimisticFavorite(!optimisticFavorite);
await favoriteContactById();
});
};
return (
<form action={favoriteContactById} onSubmit={onSubmit}>
<button
type="submit"
aria-label={
optimisticFavorite ? "Remove from favorites" : "Add to favorites"
}
>
{optimisticFavorite ? "★" : "☆"}
</button>
</form>
);
}
s;
Since the actions inside the transitions are batched, we can click the favorite button multiple times in quick succession, have multiple server actions fire, and have the final value correspond to the amount of clicks.
And thats it! Please refer to the GitHub repo for the full code.
Discussing the Two Approaches
Problem 1: Global Navigation Events
As encountered, we had to create a custom provider to handle global navigation events since next.js does not expose router events. To trigger the common transition, we made progressively enhanced client components, NavButton
and ActionButton
, that can use the useLoading
hook.
I could have just used the data-pending
attribute on the component firing a global pending state and style the layout based on it. That would also be fine for this example, but I wanted to try out a generalized solution.
Either way, we end up with a lot of boilerplate code to handle this, a trade-off for Next.js’ composition.
The correct Nextjs approach might be to use a combination of Suspense and Transitions to handle loading states, but I wanted to replicate the features of Remix tutorial as closely as possible.
Edit: See my Remix Contacts Rebuild V2 GitHub repository.
Problem 2: Search Params in Layouts
As encountered, we had to use a client component to fetch the search params in the layout component, since Next.js’s layouts does not pass search params. Luckily this method still works without JavaScript or when hydration isn’t complete.
TODO: Try this fix
The Next.js Approach
Positives
- Is more flexible and allows for more customization and composition.
- Is more familiar to React developers.
- Simplifies handling multiple forms in one route.
Neutrals
- Generates more components. This could be good for future development, but might be overkill for a small project.
Negatives
- Is more complex and requires more boilerplate code.
- Is more difficult to learn and understand.
- Deviates from web standards.
- Does not progressively enhance as well as the Remix approach.
The Remix Approach
Positives
- Is more straightforward and easier to learn and understand.
- Is more in line with web standards.
- Progressively enhances better.
- Contains less boilerplate code.
Neutrals
- Is less “React-y” and more “HTML-y”.
Conclusion
In this blog post, I’ve rebuilt the Remix Contacts tutorial in Next.js 14 using React Server Components and Server Actions, and compared the two approaches. I’ve found that the Next.js approach is more flexible and allows for more customization and composition, but is more complex and requires more boilerplate code. The Remix approach is more straightforward and easier to learn and understand, and progressively enhances better.
I hope this post has been helpful in understanding the differences between the two approaches and how to implement them in your own projects. Please let me know if you have any questions or comments, and follow me on Twitter for more updates. Happy coding! 🚀