I'm seeking a full-time remote position to join a talented team. Let's get in touch.

Building OpenTask with Next.js App Router and RSCs

Learn how I built OpenTask, an open-source MVP task management web app, using the new Next.js App Router, React Server Components, Server Actions, Suspense, Tailwind CSS, Radix, Supabase, and Prisma.

Flavio Silva
Flavio Silva • February 8, 2024
Building OpenTask with Next.js App Router and RSCs

Table of Contents

TLDR

In this article, you'll learn how I fully embraced the new React 18 and Next.js App Router features, including React Server Components, Server Actions, and Suspense, to build a full-stack hybrid web application that has never been possible, eliminating whole layers of code including no client-side data fetching, replaced by a more straightforward and faster server-side data fetching, no client-side state management, and no server-side API.

The resulting application is OpenTask: an open-source and responsive MVP task management web application.

Let's see how to combine those features to build OpenTask, with plenty of code snippets and screenshots to investigate.

Project Page
Project Page on iPhone
Project Page - OpenTask
Project Page on iPad

Introduction

In this article, I assume you're familiar with the new Next.js App Router. If you're not, you can go through the new and excellent Next.js 14 Learn course before proceeding.

Over time, web applications have become increasingly sophisticated. Fast initial page loading scalable to millions of users, search engine optimization, responsive and intuitive user interfaces that work on different devices and screen sizes, and instant data updates are some of the features that users have come to expect, and brands that deliver them are the most successful.

Nevertheless, providing all those features can become quite expensive. To address this challenge, teams rely on frameworks that continuously innovate to abstract away complex problems, reduce costs, and free up resources to create even more engaging user experiences.

In the first half of 2023, Vercel released Next.js v13.4, making the new App Router and React Server Components (RSCs) stable, and making Server Actions available in alpha. Later, in October 2023, Vercel released Next.js v14.0, making Server Actions stable.

Those new features represent a major technological innovation that has taken React and Next.js teams several years to research and develop. As Andrew Clark put it, that's the real React 18 release, and those new features are the primitives that will unfold the next layer of innovation for React apps, unlocking a new hybrid web application model.

React Server Components, Server Actions, and Suspense

React Server Components, Server Actions, and Suspense are at the forefront of the new Next.js App Router, giving birth to this new hybrid web application model, which I fully embraced to build OpenTask.

To understand how and why we got here and what problems they solve alongside the new Next.js App Router, please read my article React Server Components and a new hybrid web app model.

That article provides a solid conceptual foundation for those new features and officially introduces this article, so I assume you read it before proceeding.

Building an application to test it all

This new hybrid web app model sounds fantastic, and I was very excited to try the new Next.js App Router to see if it delivers on its promises. Does it solve real-world problems significantly better? How can I find out?

Well, there's no better way than building a real-world web application from the ground up.

I was sold. I just had to decide on what to build! 😅

What application to build?

After weighing different options, including one involving AI, I kept it simple and built a task management web application. However, I built an MVP with all the essential features users come to expect from a real-world product.

A task management app is well-known and simple enough to build as part of a case study. Nonetheless, it includes common features in most apps, such as authentication, data fetching, data mutation, data (model) relationships, and UI elements like forms and dialogs.

Of course, I also added a hint of our beloved Date Picker, and voila! OpenTask was born! 🎉

Let's get to know it.

Introducing OpenTask

OpenTask is a task management solution to help you structure your projects and tasks efficiently. With OpenTask, you can break your to-dos into small, actionable tasks and add them to appropriate projects, setting due dates and maintaining focus on one task at a time. It offers a dedicated Today page to keep you on track, helping you prioritize your next steps.

It's a stripped-down version of Todoist, and the design is also based on it.

Check out OpenTask's features page for a brief explanation with screenshots.

Key Features

Authentication

Users can create an account and authenticate effortlessly and securely, choosing from four OAuth providers: Google, GitHub, X, and LinkedIn.

If they decide to leave, deleting an account is a straightforward process from the Settings page, and all user data is permanently removed from the database upon deletion.

Projects

To create tasks, users must first create projects to which tasks are added.

Projects have a mandatory name and an optional description, and they can be deleted or archived when they're no longer active.

The Project page lists all tasks, completed and uncompleted, ensuring easy access to project-related tasks.

Archived projects are readily accessible from the Archived Projects page, allowing users to unarchive them when needed.

Tasks

Tasks form the backbone of any task management application, enabling users to manage their to-dos effectively.

Each task belongs to a specific project and includes a mandatory name, an optional description, and an optional due date to help users prioritize their work.

Tasks can be completed or deleted.

The Task page displays task data in a Dialog.

Today

The Today page simplifies daily task management. Users can view tasks due today alongside any overdue tasks, providing a clear roadmap for their day.

Technology Stack

The following is the set of tools used to build OpenTask and a brief description of each one:

TypeScript

TypeScript significantly improves JavaScript for creating high-quality, scalable, and maintainable codebases. JavaScript is a permissive language that makes it hard for developers to avoid different classes of bugs.

I've been using TypeScript since 2018 and find it incredibly helpful. I've extensively used it in OpenTask and am satisfied with its clean and typed codebase.

React 18

I have experience working with various frontend frameworks, from Backbone.js (2012) to AngularJS v1 (2013-2016). However, I immediately fell in love when I started using React by the end of 2016. Its component-based architecture, one-way data flow, and functional programming approach made it much easier yet more powerful to handle the complexity of creating modern user interfaces.

I also appreciate React's direction with its React 18 release, which includes features like React Server Components, Server Actions, and Suspense & Streaming, all leveraged by OpenTask, making React a versatile full-stack component-based solution.

Next.js App Router (v14+)

Next.js has established itself as the go-to framework for building web applications in React. Since its creation in 2016, it has continually evolved, undergoing substantial enhancements to support the ever-expanding demands of modern web development.

The recent collaboration between the React and Next.js teams has accelerated the pace of innovation and helped the React team design server-side features that are now implemented by the new Next.js App Router, including React Server Components, Server Actions, Suspense, and Streaming.

Tailwind CSS

Tailwind CSS is a utility-first CSS framework that simplifies web development by providing pre-built, highly customizable CSS utility classes. These classes allow developers to rapidly prototype and implement user interfaces without writing custom CSS. This results in a faster feedback loop for developers, who no longer need to leave their HTML files or components.

I'm a big fan of CSS-in-JS, and OpenTask was the first time I used Tailwind CSS. I had a hard time looking at all those awkward CSS class names scattered throughout HTML elements. I couldn't understand why so many great engineers liked it. But when I started OpenTask, there was no official support from any CSS-in-JS solution for the recently stable Next.js App Router. At that point, in June 2023, Tailwind seemed the only viable option alongside CSS modules.

To my surprise, I'm happy I gave it a shot. Like any tool, it comes with trade-offs, but in my experience, the advantages far outweigh the disadvantages. What stood out to me was how remarkably efficient it can be to focus solely on HTML markup + add/remove those pre-designed utility classes directly into the markup, eliminating the need for a whole CSS code layer. Other benefits include avoiding conflicts with different components when updating shared CSS code and the ability to reuse HTML/JSX code without worrying about the CSS counterpart.

Radix UI

Radix is a fantastic open-source React component library optimized for fast development, easy maintenance, and accessibility.

I'm using the Dialog, Alert Dialog, and Select Radix components in OpenTask.

Headless UI

Headless UI is an open-source collection of customizable, accessible, and unstyled UI components developed by Tailwind Labs to integrate seamlessly with Tailwind CSS.

I'm using the Menu, Switch, and Transition Headless UI components in OpenTask.

I wanted to try them, and they're excellent components. However, I'll migrate the Menu (Dropdown) and Switch components to Radix to have a consistent codebase and keep using only the Transition component from Headless UI.

Sizzy Browser

Sizzy is a web browser for developers and designers. It offers a range of features tailored to help them efficiently test and debug responsive web designs.

With Sizzy, developers and designers can view their websites in multiple device sizes and orientations simultaneously, making it easier to implement, tweak, and fix responsive web design issues, saving hours of development time.

Supabase + Postgres

Supabase is a fantastic open-source platform that simplifies Postgres database management, positioning itself as a Firebase alternative. It offers features like authentication, real-time data synchronization, and Edge Functions, making creating robust and dynamic applications more accessible.

Supabase is the database and authentication provider for OpenTask.

Prisma ORM

Prisma is an open-source Object-Relational Mapping (ORM) tool developers use to simplify database queries in JavaScript and TypeScript instead of writing raw SQL queries. Prisma automates many everyday database tasks, making handling data models, migrations, and database operations easier while ensuring type safety (when using TypeScript) in your application's data layer.

Prisma has fantastic features, including its simple and clean schema DSL to define your application's models and its migration tool to create and change your database tables based on your schema.

Drizzle is an alternative to Prisma that seems excellent, and I'm looking forward to testing it.

MDX

MDX is an extension to Markdown that lets you include JSX in Markdown documents, effectively allowing you to embed and render React components and dynamic JavaScript code within your documents.

I've used MDX to build some of the marketing pages of OpenTask.

next-pwa

next-pwa is a JavaScript library used to add Progressive Web App (PWA) capabilities to applications built with Next.js. PWAs are web applications that offer improved performance and offline functionality, and next-pwa simplifies the process of making a Next.js application into a PWA, helping developers add features like caching, service workers, and offline support.

The next-pwa library makes it incredibly easy to add many PWA features with zero config. I haven't added offline support for OpenTask, though.

Webpack

Webpack is the most popular JavaScript module bundler. It simplifies the process of managing and organizing the various components of a web application. It takes different pieces of code, such as JavaScript files, CSS files, and images, and bundles them into optimized and efficient web deployment packages. It's the default bundler when using Next.js.

Turbopack is the successor to Webpack. The goal is to make bundling as fast as a few milliseconds, improving the Developer Experience with instant code updates, refreshes, and faster production builds, increasing productivity. I'm still waiting to use Turbopack on OpenTask, but I look forward to moving to it when 100% of the tests pass.

Other dependencies

Development methodology

Developing OpenTask as a solo project allowed me to be flexible in the development approach.

I started building the user interface components and assembling them, developing the marketing and authentication pages and then the internal app pages in a responsive way.

That approach allowed me to move faster by initially focusing solely on solving UI problems.

After establishing a solid component-based UI foundation, the development transitioned to building backend services and integrating them with the front end. This phase introduced distinct challenges as the app evolved into a fully functional solution, enabling user authentication and data input to persist in the database.

For a full-stack engineer, witnessing the seamless interaction of various technologies and components of a modern web app is rewarding.

In the closing weeks, attention turned to integrating a Progressive Web App (PWA) solution, refining the UI, and addressing remaining issues.

After I finished building the MVP, I kept learning more about React Server Components and this new hybrid web application model and refactored the code several times to get to the present version.

Sitemap

Before we delve into the application architecture and implementation, let's look at all the currently available URLs to give you an overview of the application as a whole.

You can find the product at https://opentask.app.


# Marketing and legal URLs:
/
/about
/features
/pricing
/privacy
/terms
# Auth URLs:
/auth/sign-in
/auth/sign-in/check-email-link
/auth/callback
# App URLs:
/app/main-menu
/app/onboarding
/app/today
/app/projects/[projectId]
/app/projects/[projectId]/edit
/app/projects/active
/app/projects/archived
/app/projects/new
/app/tasks/[taskId]
/app/tasks/new
/app/settings/account

OpenTask's application architecture

I've put together different modular and layered application architectures in the past. When I developed OpenTask, I designed a custom-tailored application architecture for apps built with the new Next.js App Router. It's called Nexar, and OpenTask is its reference implementation.

Before reading OpenTask's implementation next, I strongly encourage you to read the Nexar docs. I use OpenTask's codebase to explain Nexar, and by reading the Nexar docs you'll thoroughly understand OpenTask's application architecture and modules alongside Nexar itself, which you can adopt in the Next.js App Router apps you build.

OpenTask's implementation

In the following sections, I'll explain how I implemented several pages and components of OpenTask to leverage the new React 18 and Next.js App Router features, including React Server Components, Server Actions, and Suspense.

I'll show several code snippets, but not everything. Feel free to browse the codebase to look into further details. Remember that this article discusses the codebase as is in the feb-2024 branch.

I plan to make only minor changes to that branch in the future so that future readers can still browse it as they follow this article.

I plan to keep working on the codebase from the main branch and writing new posts about relevant changes.

Database

OpenTask uses Supabase as the Postgres database provider and Prisma as the ORM tool.

Even though Supabase offers a database migration solution, i.e., a way to create and update database tables, I love Prisma Migrate and its clean schema DSL to handle migrations (Prisma Schema Language).

The prisma/schema.prisma file defines the three database tables in OpenTask: User, Project, and Task.

Here's the entire code for it:

schema.prisma

datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DATABASE_DIRECT_URL")
}
generator client {
provider = "prisma-client-js"
}
// We use Supabase's authentication solution, which creates users in a private "auth" schema.
// To query users from the app, we create this User table in the "public" schema.
// We add users to it after they're created by Supabase's auth solution, using the same "id"
// generated by Supabase's auth solution.
model User {
id String @id @db.Uuid
email String @unique @db.VarChar(254)
name String? @db.VarChar(500)
projects Project[]
provider String? @db.VarChar(100)
tasks Task[]
timeZone String? @db.VarChar(100)
createdAt DateTime @default(now())
updatedAt DateTime? @updatedAt
}
model Project {
id String @id @db.VarChar(32)
name String @db.VarChar(500)
description String? @db.VarChar(2000)
archivedAt DateTime?
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
authorId String @db.Uuid
tasks Task[]
createdAt DateTime @default(now())
updatedAt DateTime? @updatedAt
@@index([archivedAt, authorId])
}
model Task {
id String @id @db.VarChar(32)
name String @db.VarChar(500)
description String? @db.VarChar(2000)
dueDate DateTime?
completedAt DateTime?
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
projectId String @db.VarChar(32)
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
authorId String @db.Uuid
createdAt DateTime @default(now())
updatedAt DateTime? @updatedAt
@@index([authorId, completedAt, dueDate, projectId])
}

OpenTask has two databases (Supabase projects): a production one reflecting changes in the main branch and a staging one used for testing any branch other than main, used for building and testing new features and changing existing ones before deploying them to production.

That, alongside Vercel Previews, provides a complete staging environment for team testing, experimenting, and work review.

For local development, Supabase provides a simple way to run a local database using Docker.

That setup gives enough flexibility and scalability for small teams to keep evolving the app after its launch.

<RootLayout>

File: src/app/layout.tsx

<RootLayout> is a Server Component containing the <html> and <body> tags alongside metatags for indexing and PWA features.

Here's a simplified version of it:

layout.tsx

import { Suspense } from 'react';
import { GaNextScriptNavigation } from '@/features/shared/routing/GoogleAnalytics';
import { InstallPwaProvider } from '@/features/shared/ui/pwa/InstallPwaProvider';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<InstallPwaProvider>{children}</InstallPwaProvider>
<Suspense>
<GaNextScriptNavigation gaId="your-ga-id-here" />
</Suspense>
</body>
</html>
);
}

It renders a <InstallPwaProvider> Client Component, responsible for createContext() and providing it with a BeforeInstallPromptEvent.prompt() function that we can call when we find it appropriate. In OpenTask, we do that in the <AppLayout> (further explained).

It also renders a <GaNextScriptNavigation> Client Component, a utility I created to set Google Analytics' <script> and track visited pages automatically, so we don't have to worry about Google Analytics in any other part of the codebase.

Since we use Next.js' useSearchParams() function in <GaNextScriptNavigation>, we have to wrap it in <Suspense> to avoid getting the following error at build time:


useSearchParams() should be wrapped in a suspense boundary at page "/app/(.)projects".
Read more: https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout

Marketing pages and components

Routing module: src/app/(marketing)
Feature module: src/features/marketing/

The following is the folder structure of the src/app/(marketing) routing module, defining marketing and legal route segments:


src/app/(marketing)/layout.tsx
src/app/(marketing)/page.tsx
src/app/(marketing)/about/page.mdx
src/app/(marketing)/features/page.tsx
src/app/(marketing)/pricing/page.tsx
src/app/(marketing)/privacy/page.mdx
src/app/(marketing)/terms/page.mdx

The following is the folder structure of the src/features/marketing/ business feature module:


shared/ui/Footer.tsx
shared/ui/Header.tsx
shared/ui/HeroCopy.tsx
shared/ui/HeroHeading.tsx
shared/ui/MainMenu.tsx
shared/ui/MainMenuMobile.tsx
shared/ui/ShowContentTransition.tsx

I explain routing and business feature modules, alongside other codebase conventions you'll see in the following sections, in Nexar, the application architecture OpenTask implements. If you haven't read it, please do so now, as it'll help you understand OpenTask's implementation more easily and quickly. I explain Nexar with OpenTask's codebase, so you'll learn both simultaneously.

<MarketingLayout>

File: src/app/(marketing)/layout.tsx

We define a (marketing) Route Group in the routing module to implement a <MarketingLayout> Server Component that wraps marketing and legal pages only and is wrapped by <RootLayout>.

Here's the entire code for <MarketingLayout>:

layout.tsx

import '../globals.css';
import { Header } from '@/features/marketing/shared/ui/Header';
import { Footer } from '@/features/marketing/shared/ui/Footer';
export default function MarketingLayout({ children }: { children: React.ReactNode }) {
return (
<>
<div className="flex flex-1 flex-col bg-white">
<Header />
<div className="relative px-6 lg:px-8">
<div className="mx-auto max-w-2xl">
<div className="text-center mb-20">{children}</div>
</div>
</div>
</div>
<Footer />
</>
);
}

<Header> is a Client Component that useState() to render a <MainMenuMobile> conditionally when we click the hamburger menu button.

<Footer> is a Shared Component that renders static HTML only.

React Server Components as a static site generation (SSG) tool

We should use Server Components as much as possible due to their numerous benefits, including zero impact on bundle size and server-side data fetching, moving Client Components down the tree.

In Next.js App Router, Server Components render at build time by default unless you use a dynamic function like cookies() or headers(), which will opt into dynamic rendering.

Since we don't use any dynamic function in marketing pages, we effectively use React Server Components as a static site generation (SSG) tool that doesn't require a server at runtime.

All marketing and legal pages are straightforward Server Components rendering static HTML.

The <Header>, <MainMenu>, <MainMenuMobile>, and <ShowContentTransition> are the only Client Components in marketing pages, which means they're the only components React hydrates in the browser.

Server Components are rendered in the server or at build time to plain HTML and are not hydrated by React. That's why they can't have any interactivity or use any browser API.

Previously, I was redirecting authenticated users to the app (opentask.app/app) from the marketing layout, but that turns all marketing pages dynamic, waiting for server-side code to check if users are authenticated, resulting in a somewhat slow rendering of the marketing pages for not authenticated users.

I could look into optimizing it, but for now, I removed the redirect, making all marketing and legal pages static, allowing us to cache and serve them from a CDN. The sign-in page redirects users to the app when they are authenticated, as is further explained.

Landing page


Landing Page
Landing Page on iPhone
Landing Page -
OpenTask
Landing Page on iPad

File: src/app/(marketing)/page.tsx

Here's the entire code for the landing page and related components:

page.tsx

import Link from 'next/link';
import { buttonGreenClassName } from '@/features/shared/ui/control/button/buttonClassName';
import { HeroCopy } from '@/features/marketing/shared/ui/HeroCopy';
import { HeroHeading } from '@/features/marketing/shared/ui/HeroHeading';
import { ShowContentTransition } from '@/features/marketing/shared/ui/ShowContentTransition';
export default function LandingPage() {
return (
<ShowContentTransition>
<div className="pt-24 sm:pt-36 lg:pt-42">
<HeroHeading>
Free and Open Source <br />
<span className="text-green-700">Task Manager</span>
</HeroHeading>
<HeroCopy>
Get focused by organizing your plans and goals with simple projects and tasks.
</HeroCopy>
<div className="mt-10 flex items-center justify-center">
<Link href="/auth/sign-in" className={buttonGreenClassName}>
Get Started
</Link>
</div>
</div>
</ShowContentTransition>
);
}

ShowContentTransition.tsx

'use client';
import { Transition } from '@headlessui/react';
import { ChildrenProps } from '@/features/shared/ui/ChildrenProps';
export const ShowContentTransition = ({ children }: ChildrenProps) => (
<Transition
appear
show
as="div"
enter="ease-out duration-[400ms]"
enterFrom="opacity-0 translate-y-[75px]"
enterTo="opacity-100 translate-y-0"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-[75px]"
>
{children}
</Transition>
);

HeroHeading.tsx

import { ChildrenProps } from '@/features/shared/ui/ChildrenProps';
export const HeroHeading = ({ children }: ChildrenProps) => (
<h1 className="text-3xl font-bold tracking-tight text-gray-900 sm:text-6xl sm:leading-tight">
{children}
</h1>
);

HeroCopy.tsx

import { twMerge } from 'tailwind-merge';
import { ChildrenProps } from '@/features/shared/ui/ChildrenProps';
import { ClassNamePropsOptional } from '@/features/shared/ui/ClassNameProps';
export const HeroCopy = ({ children, className }: ChildrenProps & ClassNamePropsOptional) => (
<p className={twMerge('mt-6 text-xl leading-8 text-gray-800', className)}>{children}</p>
);

The other marketing pages share this same structure. Legal pages are even more straightforward.

Authentication pages and components

Routing module: src/app/auth
Feature module: src/features/auth/

The following is the folder structure of the src/app/auth routing module, defining authentication route segments:


src/app/auth/error.tsx
src/app/auth/layout.tsx
src/app/auth/sign-in/page.tsx
app/auth/sign-in/check-email-link/page.tsx

The following is the folder structure of the src/features/auth/ business feature module:


data-access/AuthDataAccess.ts
data-access/OAuthProvider.ts

<AuthLayout>

File: src/app/auth/layout.tsx

<AuthLayout> is a Server Component that wraps authentication pages and is wrapped by <RootLayout>.

It renders a more straightforward layout for authenticated pages, rendering only the OpenTask logo instead of the <Header> component used in the marketing pages. The <Footer> component is the same.

Authentication functionality

Users authenticate using one of the OAuth providers implemented in the <SignInPage> (more next).

OpenTask's authentication is based on Supabase's authentication solution and implemented server-side to support React Server Components, which means it's cookies-based.

We can authenticate with email OTP (magic link) only from localhost and preview deployment because that's easier than setting up OAuth apps for testing. You can find instructions on how to run the app locally in the repo's README file.

I left out the email OTP authentication option because it doesn't work on the iPhone when running the app as a PWA, i.e., outside Safari.

When we click the OTP sign-in link from the iPhone's email app, it opens Safari and gets us signed in. However, the session is not shared with the PWA, which runs in a dedicated window, i.e., we're not signed in when we switch back to the PWA window.

It works on Android when switching from Chrome to the PWA, but we shouldn't expect users to switch back to the PWA window; that's a terrible UX.

The expected UX is clicking the OTP magic link in the email app and going directly to the PWA window to get authenticated. I couldn't make it work, though. If you did that before, please leave a comment below or ping me on X. I'd love to make it work if it's doable. I'm using Giscus, so you can use your GitHub account to comment.

Many mobile authentication options have great UX, but I kept it simple for the MVP.

Clerk seems like an excellent authentication solution with such options, and I look forward to testing it.

Middleware

The src/middleware.ts file leverages Next's middleware functionality to refresh the user's session before loading Server Component routes as per Supabase's documentation.

AuthDataAccess.ts

AuthDataAccess.ts exposes two Server Actions: signInWithEmail(), called only when testing email sign-in on localhost or preview deployment, and signInWithOAuth(), called when signing-in with OAuth in production.

I'm using Supabase's @supabase/auth-helpers-nextjs package (see the docs), but they recently released a replacement one called @supabase/ssr (see docs here), which they now recommend using instead.

Moving forward, I'll migrate to either @supabase/ssr or Clerk. I'm leaning toward Clerk, which I recommend to my clients when building commercial apps.

<SignInPage>


Sign In Page
Sign In Page on iPhone
Sign In Page
Sign In Page on iPad

File: src/app/auth/sign-in/page.tsx

<SignInPage> is a simple Server Component rendering, among a few other things, four <OAuthProviderButton> Server Components, one for each provider.

Here's a simplified version of it:

page.tsx

import { GoogleLogoIcon } from '@/features/shared/ui/icon/GoogleLogoIcon';
import { OAuthProviderButton } from '@/features/shared/ui/control/button/OAuthProviderButton';
import { OAuthProvider } from '@/features/auth/data-access/OAuthProvider';
import { signInWithOAuth } from '@/features/auth/data-access/AuthDataAccess';
...
export default async function SignInPage() {
...
return (
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
<OAuthProviderButton
action={signInWithOAuth}
provider={OAuthProvider.Google}
>
<GoogleLogoIcon />
Continue with Google
</OAuthProviderButton>
...
</div>
);
...
}

And here's the entire code for <OAuthProviderButton>:

OAuthProviderButton.tsx

import { twMerge } from 'tailwind-merge';
import { OAuthProvider } from '@/features/auth/data-access/OAuthProvider';
import { ChildrenProps } from '@/features/shared/ui/ChildrenProps';
import { buttonWhiteClassName } from './buttonClassName';
import { SubmitButton } from './SubmitButton';
export interface OAuthProviderButtonProps extends ChildrenProps {
readonly action: (formData: FormData) => void;
readonly provider: OAuthProvider;
}
export const OAuthProviderButton = ({ action, children, provider }: OAuthProviderButtonProps) => (
<form action={action}>
<input type="hidden" name="provider" value={provider} />
<SubmitButton
className={twMerge(buttonWhiteClassName, 'mt-4 w-full')}
labelClassName="gap-2"
spinnerClassName="border-green-600 border-b-white"
>
{children}
</SubmitButton>
</form>
);

The <SubmitButton> leverages useFormStatus() to display a spinner loading when pending === true.

As mentioned earlier, on localhost and preview deployment, we render a <form action={signInWithEmail}> allowing authentication with email. When we npx supabase start, it launches Inbucket on localhost at port 54324. We can then navigate to /monitor to see incoming emails, open them, and click the Confirm your email address link to sign in on localhost or a preview deployment. I always use email authentication on localhost and preview deployment. I didn't set up OAuth for testing.

Another important thing we do in the <SignInPage> is redirect authenticated users to the app:

page.tsx

import { redirect } from 'next/navigation';
import { isUserAuthenticated } from '@/features/app/users/UsersDataAccess';
...
export default async function SignInPage() {
/*
* Redirect authenticated users to the app.
*/
const isAuthenticated = await isUserAuthenticated();
if (isAuthenticated) redirect('/app/today');
/**/
...
}

I love how easy and intuitive implementing such a redirect directly from a React component is. If we don't redirect, the component renders as usual. That's a tiny example of how amazing React Server Components are and how they seamlessly fit in a React component tree.

The downside is that <SignInPage> becomes dynamic and can't be cached and served from a CDN. But there's no way around it if we don't want to show a sign-in page for authenticated users.

<AppLayout>

File: src/app/app/layout.tsx

We use the <AppLayout> Server Component to compose the application layout, render children (the target page.tsx route segment) and dialog (a named slot to create an Intercepting Route for dialogs) and redirect unauthenticated users to the authentication page.

Here's a simplified version of it:

layout.tsx

import { redirect } from 'next/navigation';
import { Header } from '@/features/app/shared/ui/Header';
import { MainMenu } from '@/features/app/shared/ui/MainMenu';
import { InstallPwaDialog } from '@/features/shared/ui/pwa/InstallPwaDialog';
import { isUserAuthenticated } from '@/features/app/users/UsersRepository';
...
export default async function AppLayout({
children,
dialog,
}) {
/*
* Redirect unauthenticated users to the authentication page.
*/
const isAuthenticated = await isUserAuthenticated();
if (!isAuthenticated) redirect('/auth/sign-in');
/**/
return (
<div>
<Header />
<div>
<MainMenu className="hidden lg:flex" />
<div>
{children}
{dialog}
</div>
</div>
<InstallPwaDialog />
</div>
);
}

We render a <Header> Server Component that fetches the authenticated user object to pass its name to the <SettingsMenu> Client Component. That's a different <Header> component than the marketing pages one, as you can see it imported from @/features/app/shared/ui/Header.

Here's a simplified version of <Header>:

Header.tsx

import { SettingsMenu } from '@/features/app/settings/ui/SettingsMenu';
import { getUser } from '@/features/app/users/data-access/UsersDataAccess';
export const Header = async () => {
const user = await getUser();
return (
<header>
...
<SettingsMenu userName={user.name} />
...
</header>
);
};

<SettingsMenu> is a Client Component, so it can't fetch the user object. It renders a <PersonIcon> button that renders a <DropdownMenu> when clicked.

We render the app's <MainMenu className="hidden lg:flex" /> Server Component (another one, not the same as the marketing pages) only on large screens, i.e., @media (min-width: 1024px).

For small screens, we have a hamburger menu button in the <Header> that navigates to the /app/main-menu route to render the same <MainMenu> component but this time fullscreen in a <Dialog> for a better UX for mobile users.

We render a <InstallPwaDialog /> Client Component to show a one-time dialog asking Android and iOS users to install the PWA right after the first sign-in.

Android users see an Add to Home Screen button. When they click it, we call the BeforeInstallPromptEvent.prompt() function from the context provided by <InstallPwaProvider> as we saw explained here to add the app to the home screen.

Install PWA on Android
Install PWA on Android

Since BeforeInstallPromptEvent.prompt() is unavailable on iOS, we instruct iOS users to add it to the home screen.

Install PWA on iPhone
Install PWA on iPhone

We use localStorage to prevent showing this dialog more than once for the same device.

File: src/features/app/shared/ui/MainMenu.tsx

<MainMenu> is a Server Component that renders a <ProjectList> Server Component alongside a couple other buttons.

Here's a simplified version of it:

MainMenu.tsx

import { Suspense } from 'react';
import { ProjectList } from '@/features/app/projects/ui/ProjectList';
import { ProjectListSkeleton } from '@/features/app/projects/ui/ProjectListSkeleton';
export const MainMenu = ({
className
}: ClassNamePropsOptional) => {
return (
...
<Suspense fallback={<ProjectListSkeleton />}>
<ProjectList only="active" />
</Suspense>
...
);
}

We'll look into <ProjectList> moving forward.

Main Menu Dialog Page on
iPhone
Main Menu Dialog Page on iPhone

File: src/app/app/@dialog/(.)main-menu/page.tsx

The /app/main-menu route is only accessible on small screens. It leverages Next's Intercepting Routes feature to render the <MainMenuDialogPage> Server Component in a <Dialog>.

Here's the entire code for it:

page.tsx

import { Dialog } from '@/features/shared/ui/dialog/Dialog';
import { MainMenu } from '@/features/app/shared/ui/MainMenu';
import { RouterActions } from '@/features/shared/routing/RouterActions';
export default function MainMenuDialogPage() {
return (
<Dialog>
<MainMenu />
</Dialog>
);
}

When we're at /app/main-menu route and refresh the browser, the regular route component is rendered instead of the Intercepting Route one. That regular route component is <MainMenuPage>, located at src/app/app/main-menu/page.tsx.

Main Menu Page on iPhone
Main Menu Page on iPhone

Here's the entire code for it:

page.tsx

import { MainMenu } from '@/features/app/shared/ui/MainMenu';
export default function MainMenuPage() {
return <MainMenu className="mt-8" />;
}

Notice we don't wrap <MainMenu> in a <Dialog> here.

Onboarding page



Onboarding Page
Onboarding Page on iPhone
Onboarding Page -
OpenTask
Onboarding Page on iPad

File: src/app/app/onboarding/page.tsx

<OnboardingPage> is a pretty straightforward Server Component. It fetches all user's projects and either renders itself to ask the user to create a first project or redirects them to the <TodayPage> if they already have projects.

Here's the entire code for it:

page.tsx

import Link from 'next/link';
import { redirect } from 'next/navigation';
import { twMerge } from 'tailwind-merge';
import { buttonGreenClassName } from '@/features/shared/ui/control/button/buttonClassName';
import { ErrorList } from '@/features/shared/ui/error/ErrorList';
import { getProjects } from '@/features/app/projects/data-access/ProjectsDataAccess';
export default async function OnboardingPage() {
const { data: projects, errors } = await getProjects();
if (errors) return <ErrorList errors={errors} />;
if (projects && projects.length > 0) redirect('/app/today');
return (
<>
<h2 className="mt-6 text-2xl">Welcome to OpenTask!</h2>
<p className="mt-8 text-sm font-medium">You don&#39;t have any projects yet.</p>
<Link href="/app/projects/new" className={twMerge(buttonGreenClassName, 'w-fit mt-8')}>
Create your first!
</Link>
</>
);
}

Project pages and components

Routing module: src/app/app/projects
Feature module: src/features/app/projects

Let's now move to the implementation of the project pages.

<NewProjectDialogPage> and <NewProjecPage>

<TodayPage> renders tasks, and tasks depend on pre-existing projects to be created. Therefore, let's first look into how project and task pages are implemented before finishing with an explanation of how the <TodayPage> page is created. That will bring our series of explanations to a close.

When we click the + icon button next to the Projects button in <MainMenu>, we soft navigate to /app/projects/new. Since we set up an Intercepting Route for it, <NewProjectDialogPage> gets rendered, which is located at src/app/app/@dialog/(.)projects/new/page.tsx:

New Project Dialog
Page
New Project Dialog Page on iPhone
New Project Dialog Page -
OpenTask
New Project Dialog Page on iPad

The <NewProjectDialogPage> is an excellent example of how this new hybrid web application model works, depicting a lovely hybrid React component tree.

Here's the entire code for it:

page.tsx

import { RouterActions } from '@/features/shared/routing/RouterActions';
import { Dialog } from '@/features/shared/ui/dialog/Dialog';
import { ProjectForm } from '@/features/app/projects/ui/ProjectForm';
export default function NewProjectDialogPage() {
return (
<Dialog defaultOpen title="Create project" routerActionOnClose={RouterActions.Back}>
<ProjectForm className="mt-6" />
</Dialog>
);
}

<Dialog> is a Client Component wrapping Radix's Dialog component, which handles client (browser) APIs such as the esc key press to close itself.

<ProjectForm> is a Server Component that's also reused in <EditProjectDialogPage>, as we'll see next. When editing projects, we pass a project ID like <ProjectForm projectId="ID_HERE">, and it fetches the project and fills in the form fields.

It turns out that that hybrid component tree is also a great example of how to interleave Client and Server Components: a Client Component, <Dialog>, receives and renders a Server Component, <ProjectForm>, as children.

But Intercepting Routes only render when we soft navigate to them. If we refresh the browser while at /app/projects/new, or share that link with someone who opens it, the regular route component is rendered instead of the Intercepting Route one. That regular route component is <NewProjectPage>, located at src/app/app/projects/new/page.tsx.

For those use cases, we don't have a previously rendered page to render a dialog over, so we don't render a dialog at all. We render it as a self-contained page:

New Project Page
New Project Page on iPhone
New Project Page -
OpenTask
New Project Page on iPad

Here's the entire code for it:

page.tsx

import { ProjectForm } from '@/features/app/projects/ui/ProjectForm';
export default function NewProjectPage() {
return (
<div className="flex flex-col mt-10">
<h1 className="text-xl text-gray-800">Create project</h1>
<ProjectForm className="mt-6" />
</div>
);
}

It works just like the dialog version, though. Nice and easy, huh?

You can go ahead and create a project now.

What? You haven't signed up? You're kidding, right? 😅

Please go ahead and sign up now and create your first project to follow these explanations more closely. You can delete your account at any time on the Settings page, and all your data will be immediately and permanently deleted. No soft deletes and no backups. OpenTask is an engineering case, not an actual product.

And if you see any bugs while you're testing it, please create an issue. If you have any thoughts, please share them in the discussions or in the comments below.

Let's make it a nice community project to experiment and discover new React and Next.js patterns and solutions to real-world problems while we improve ourselves and help others.

Before moving to <ProjectForm>, let's look into <EditProjectDialogPage> and <EditProjectPage>.

<EditProjectDialogPage> and <EditProjectPage>

After we create a project, we're redirected to the project page at /app/projects/[projectId], which renders <ProjectPage> (details to follow). There, you can see the three-dot button. Clicking it pops up a menu:

Project Page
Menu
Project Page Menu on iPhone

Clicking Edit project opens the <EditProjectDialogPage>:

Edit Project Dialog
Page
Edit Project Dialog Page on iPhone
Edit Project Dialog Page -
OpenTask
Edit Project Dialog Page on iPad

Because <ProjectForm> does the hard work (data fetching), all we have to do is render it passing a projectId.

Here's the entire code for <EditProjectDialogPage>, located at src/app/app/@dialog/(.)projects/[projectId]/edit/page.tsx:

page.tsx

import { Suspense } from 'react';
import { Dialog } from '@/features/shared/ui/dialog/Dialog';
import { RouterActions } from '@/features/shared/routing/RouterActions';
import { ProjectForm } from '@/features/app/projects/ui/ProjectForm';
import { ProjectFormSkeleton } from '@/features/app/projects/ui/ProjectFormSkeleton';
interface EditProjectDialogPageProps {
readonly params: { readonly projectId: string };
}
export default function EditProjectDialogPage({
params: { projectId },
}: EditProjectDialogPageProps) {
return (
<Dialog defaultOpen title="Edit project" routerActionOnClose={RouterActions.Back}>
<Suspense fallback={<ProjectFormSkeleton className="mt-6" ssrOnly="Loading project..." />}>
<ProjectForm className="mt-6" projectId={projectId} />
</Suspense>
</Dialog>
);
}

We wrap <ProjectForm> in <Suspense> to render a <Dialog> with a title and a <ProjectFormSkeleton> right away, while <ProjectForm> fetches the project data. When data is loaded, <ProjectForm> is rendered on the server and streamed to the browser. That's streaming server rendering.

I love the easy implementation of such advanced features, resulting in a great user experience while controlling their look and where to put them.

If we refresh the page, we also render a regular route component, not the Intercepting Route one. That route component is <EditProjectPage>, located at src/app/app/projects/[projectId]/edit/page.tsx.

Edit Project Page
Edit Project Page on iPhone
Edit Project Page -
OpenTask
Edit Project Page on iPad

Here's the entire code for it:

page.tsx

import { Suspense } from 'react';
import { ProjectForm } from '@/features/app/projects/ui/ProjectForm';
import { ProjectFormSkeleton } from '@/features/app/projects/ui/ProjectFormSkeleton';
interface EditProjectPageProps {
readonly params: { readonly projectId: string };
}
export default function EditProjectPage({ params: { projectId } }: EditProjectPageProps) {
return (
<div className="flex flex-col mt-10">
<h1 className="text-xl text-gray-800">Edit project</h1>
<Suspense fallback={<ProjectFormSkeleton className="mt-6" ssrOnly="Loading project..." />}>
<ProjectForm className="mt-6" projectId={projectId} />
</Suspense>
</div>
);
}

Could it be simpler? Well, I like it a lot!

We also wrap <ProjectForm> in <Suspense> to render a title and skeleton immediately while waiting for the project data to be loaded and presented to the user.

Now, let's see how <ProjectForm> is implemented.

<ProjectForm>

File: src/features/app/projects/ui/ProjectForm.tsx

<ProjectForm> was the first form component I built with the new App Router and React 18 canary features like <form action={}>, useFormStatus(), and useFormState().

I wanted to figure out the best new way to build forms in React, and I wanted it to be as simple as possible and a Server Component because of its many benefits. I didn't get it right initially; it was a Client Component, but I managed to refactor it later to get to the present Server Component version.

Here's the entire code for it:

ProjectForm.tsx

'use server';
import 'server-only';
import { notFound } from 'next/navigation';
import { twMerge } from 'tailwind-merge';
import { ClassNamePropsOptional } from '@/features/shared/ui/ClassNameProps';
import { buttonGreenClassName } from '@/features/shared/ui/control/button/buttonClassName';
import { SubmitButton } from '@/features/shared/ui/control/button/SubmitButton';
import { inputTextClassName } from '@/features/shared/ui/control/input/inputTextClassName';
import { ServerError } from '@/features/shared/data-access/ServerResponse';
import { ErrorList } from '@/features/shared/ui/error/ErrorList';
import { Form } from '@/features/shared/ui/form/Form';
import { FormErrorList } from '@/features/shared/ui/form/FormErrorList';
import {
ProjectDto,
createProject,
getProjectById,
updateProject,
} from '../data-access/ProjectsDataAccess';
export interface ProjectFormProps extends ClassNamePropsOptional {
readonly projectId?: string;
}
export const ProjectForm = async ({ className, projectId }: ProjectFormProps) => {
let project: ProjectDto | undefined | null;
let errors: Array<ServerError> | undefined;
if (projectId) ({ data: project, errors } = await getProjectById(projectId));
if (errors) return <ErrorList errors={errors} />;
if (projectId && !project) notFound();
const name = (project && project.name) ?? '';
const description = (project && project.description) ?? '';
const formAction = project ? updateProject : createProject;
return (
<Form action={formAction}>
{project && <input type="hidden" name="id" value={project.id} />}
<input
autoFocus
defaultValue={name}
name="name"
type="text"
placeholder="Project name"
className={twMerge(inputTextClassName, 'mb-6')}
required
minLength={1}
maxLength={500}
autoComplete="off"
/>
<textarea
defaultValue={description}
name="description"
placeholder="Project description"
rows={5}
maxLength={500}
className={twMerge(inputTextClassName, 'mb-6 resize-none')}
></textarea>
<FormErrorList />
<SubmitButton className={twMerge(buttonGreenClassName, 'flex self-end')}>Save</SubmitButton>
</Form>
);
};

I've been building React apps (and forms!) since 2016, and I like that a lot. It's clean and easy to follow.

First and foremost, let's remember that this form component supports creating and editing projects besides fetching the project data to be edited.

That allows us to throw it in any component tree, passing a projectId to it. We don't have to worry about its data dependencies, i.e., how it fetches and persists data.

I'm calling this kind of component a full-stack component: a self-contained data-driven Server Component that's fully reusable and composable despite its data dependencies.

That has never been possible before and is now possible thanks to React Server Components.

The lack of a state management is noteworthy. It is, after all, a Server Component, and Server Components are stateless and non-interactive. It has to be a Server Component to fetch project data we want to edit.

Once you understand Server Components, you appreciate how it simplifies the code, reducing complexity and the cognitive load to understand your components.

We can easily follow the code from top to bottom because even though it fetches data asynchronously, we don't have to worry about the component lifecycle.

Server Components don't have any lifecycle. The logic flows from top to bottom and ends there.

That's why we don't use hooks in Server Components, we don't need them.

Hooks don't apply to Server Components.

The following line of code assigns the correct Server Action used in <form action={}>:


const formAction = project ? updateProject : createProject;

When we're editing a project, the following line of code renders a hidden input field with the project.id:


{
project && <input type="hidden" name="id" value={project.id} />;
}

I like returning to this old HTML standard. We don't have to build JavaScript objects to send data to the server; that's handled automatically.

I also like the App Router convention of having a separate not-found.tsx page component that we can easily render in place of the current page:


if (projectId && !project) notFound();

This component is located at src/app/app/projects/not-found.tsx.

We could refactor how to display errors when fetching project data to leverage the same convention, this time throwing a JavaScript error to render an error.tsx instead of manually rendering the errors as I'm doing here:


if (errors) return <ErrorList errors={errors} />;

That's something I want to check out eventually. It's not a big deal, but I want to keep the codebase aligned with the App Router conventions.

Even though <ProjectForm> is a Server Component, <Form>, <FormErrorList>, and <SubmitButton> are Client Components.

Let's take a look at each one of them.

<Form>

<Form> is a reusable component I built to provide a few utility features around form functionality, besides rendering <form action={}>.

For <ProjectForm>, we only use a single custom feature of it, which is the only reason we use it instead of a plain <form action={}>.

That feature is the creation of a context to provide the response object from the server (see below).

<FormErrorList>

That response object is used by <FormErrorList> to check for errors coming from the server.

Here's the entire code for it:

FormErrorList.tsx

'use client';
import 'client-only';
import { useContext } from 'react';
import { ErrorList } from '@/features/shared/ui/error/ErrorList';
import { FormContext } from './Form';
export const FormErrorList = () => {
const { response } = useContext(FormContext);
if (!response || !response.errors) return null;
return <ErrorList errors={response.errors} />;
};

<ErrorList> just renders a list of errors.

<SubmitButton>

The <SubmitButton> Client Component renders a <button type="submit"> and useFormStatus() to render a spinner loading when pending === true.

Data validation

We must always validate data in the server before persisting it to the database.

But it's generally a good practice to validate data on the client side, too, for faster feedback and an improved UX instead of allowing the user to submit invalid data and wait for the server to respond with errors.

<ProjectForm> has only two input fields, name and description, and only name is madatory.

We use standard HTML form validation here, but we could reuse the same zod validation method we use in the server and display error messages below the input field for an improved UX. That's a good enhancement for the future.

We can easily spot other features from the code, like using the standard HTML autofocus attribute.

Improved experience building forms

Overall, we have a much-improved experience building forms in React without any third-party library.

The new React APIs, including <form action={}>, useFormStatus(), and useFormState(), make it less complex to build forms with basic features. There is always room for improvement, but React is getting better at tackling this problem.

Thrid-party form libraries

Even though I'm not using a third-party form library in OpenTask, I'm sure plenty of advanced use cases still exist to use great libraries like React Hook Form.

When an external library makes it substantially simpler, we should use it.

createProject()

When creating new projects, we submit the form data to the createProject() Server Action.

It validates FormData with zod, gets the authenticated user ID from the current session, generates a new project ID with cuid2(), and calls prisma.project.create().

If there's an error validating data with zod, we return those errors to display in the UI.

If there's an error unrelated to data validation, we return a generic, friendly error message to the user instead of the unknown one. We should log that unknown error to a logging/monitoring service. That's currently pending in OpenTask and is another good future enhancement.

If there are no errors, we redirect the user to the recently created project page.

Here's the entire code for createProject(), extracted from src/features/app/projects/data-access/ProjectsDataAccess.ts:

ProjectsDataAccess.ts

'use server';
import 'server-only';
import { redirect } from 'next/navigation';
import { z } from 'zod';
import { cuid2 } from '@/features/shared/data-access/cuid2';
import { prisma } from '@/features/shared/data-access/prisma';
import {
ServerResponse,
createServerErrorResponse,
createServerSuccessResponse,
} from '@/features//shared/data-access/ServerResponse';
import { genericAwareOfInternalErrorMessage } from '@/features/shared/ui/error/errorMessages';
import { getServerSideUser } from '@/features/app/users/data-access/UsersDataAccess';
import {
createProjectSchema,
deleteProjectSchema,
updateProjectSchema,
} from '../domain/ProjectsDomain';
export type CreateProjectDto = z.infer<typeof createProjectSchema>;
export type UpdateProjectDto = z.infer<typeof updateProjectSchema>;
export type ProjectDto = CreateProjectDto & { id: string };
export const createProject = async (
prevResponse: ServerResponse<ProjectDto> | undefined,
formData: FormData,
) => {
const validation = createProjectSchema.safeParse(Object.fromEntries(formData));
if (!validation.success) {
console.error(validation.error);
// return Zod validation errors.
return createServerErrorResponse<ProjectDto>(validation.error);
}
let result;
try {
const { data } = validation;
const { id } = await getServerSideUser();
result = await prisma.project.create({
data: {
author: {
connect: {
id,
},
},
...data,
id: cuid2(),
},
});
} catch (error) {
console.error(error);
// return a friendly error message instead of the unknown real one.
return createServerErrorResponse<ProjectDto>(genericAwareOfInternalErrorMessage);
}
redirect(`/app/projects/${result.id}`);
};

We don't return the result data from this Server Action because we redirect the user to a different route, unmounting the component calling createProject().

The updateProject() Server Action is very similar.

<ActiveProjectsPage> and <ArchivedProjectsPage>

File: src/app/app/projects/active/page.tsx

File: src/app/app/projects/archived/page.tsx

Clicking the Projects button in <MainMenu> navigates to /app/projects/active, rendering the <ActiveProjectsPage> route component:

Active Projects Page
Active Projects Page on iPhone
Active Projects Page -
OpenTask
Active Projects Page on iPad

Here's the entire code for it:

page.tsx

import { Suspense } from 'react';
import { ProjectList } from '@/features/app/projects/ui/ProjectList';
import { ProjectListSkeleton } from '@/features/app/projects/ui/ProjectListSkeleton';
import { ProjectsPageHeader } from '@/features/app/projects/ui/ProjectsPageHeader';
export default function ActiveProjectsPage() {
const empty = <p className="text-sm font-medium text-gray-600">No active projects.</p>;
return (
<>
<ProjectsPageHeader archived={false} />
<Suspense fallback={<ProjectListSkeleton ssrOnly="Loading projects..." />}>
<ProjectList empty={empty} itemClassName="pl-2" only="active" />
</Suspense>
</>
);
}

We can use the switch component to navigate between active (/app/projects/active) and archived projects (/app/projects/archived).

Here's the <ArchivedProjectsPage>:

Archived Projects Page
Archived Projects Page on iPhone
Archived Projects Page -
OpenTask
Archived Projects Page on iPad

And here's the entire code for it:

page.tsx

import { Suspense } from 'react';
import { ProjectList } from '@/features/app/projects/ui/ProjectList';
import { ProjectListSkeleton } from '@/features/app/projects/ui/ProjectListSkeleton';
import { ProjectsPageHeader } from '@/features/app/projects/ui/ProjectsPageHeader';
export default function ArchivedProjectsPage() {
const empty = <p className="text-sm font-medium text-gray-600">No archived projects.</p>;
return (
<>
<ProjectsPageHeader archived={true} />
<Suspense fallback={<ProjectListSkeleton ssrOnly="Loading projects..." />}>
<ProjectList empty={empty} itemClassName="pl-2" only="archived" />
</Suspense>
</>
);
}

It's exactly the same as <ActiveProjectsPage> except for three things: slightly different empty message, passes archived={true} to <ProjectsPageHeader> (the component rendering <Switch>) instead of archived={false}, and passes only="archived" to <ProjectList> instead of only="active".

Before looking into <ProjectList>, let's recap how <MainMenu> renders it.

Recapt <MainMenu>

This is how <MainMenu> render <ProjectList>:

MainMenu.tsx

<Suspense fallback={<ProjectListSkeleton />}>
<ProjectList only="active" />
</Suspense>

Using <Suspense> to stream <ProjectList>

Notice that everywhere we render <ProjectList> we wrap it in <Suspense>.

That's not mandatory, but it's a good practice that allows pages to render immediately while <ProjectList> fetches data asynchronously.

To signal users the app is loading data that will be displayed soon, we pass fallback={<ProjectListSkeleton />} to <Suspense>.

Once data is loaded and the component is rendered on the server, it's streamed to the browser, replacing <ProjectListSkeleton>.

<ProjectList> and <ProjectListItem>

File: src/features/app/projects/ui/ProjectList.tsx

File: src/features/app/projects/ui/ProjectListItem.tsx

<ProjectList> is another full-stack component, i.e., a self-contained data-driven Server Component that's fully reusable and composable despite its data dependencies.

We've just seen how easy it is to reuse and compose <ProjectList> anywhere. Since it resolves its data dependency, we only have to render it using its clean API. We don't have to worry about its data dependencies.

Because <ProjectList> rendering logic is coupled with data-fetching logic, we design and expose a clean API for consumer components to filter what project data they want.

We should do that for every full-stack component like that to give consumers the flexibility to render components in different use cases.

For <ProjectList>, the only data-related API is the only attribute, which can be active, archived, or nothing, rendering a list of active and archived projects.

Here's the entire code for it:

ProjectList.tsx

'use server';
import 'server-only';
import { twMerge } from 'tailwind-merge';
import { ClassNamePropsOptional } from '@/features/shared/ui/ClassNameProps';
import { ErrorList } from '@/features/shared/ui/error/ErrorList';
import { ProjectListItem } from './ProjectListItem';
import { getProjects } from '../data-access/ProjectsDataAccess';
interface ProjectListProps extends ClassNamePropsOptional {
readonly activateItem?: boolean;
readonly activeItemClassName?: string;
readonly empty?: React.ReactNode;
readonly itemClassName?: string;
readonly itemHref?: string;
readonly only?: 'active' | 'archived';
}
/*
* I'm suppressing the following TypeScript error that seems to be an issue
* with React types for async components:
*
* "Type is referenced directly or indirectly in the fulfillment callback of its own 'then' method.ts(1062)"
*/
// @ts-ignore
export const ProjectList = async ({
activeItemClassName,
className,
empty,
itemClassName,
itemHref,
only,
}: ProjectListProps) => {
const { data: projects, errors } = await getProjects({
...(only && { isArchived: only === 'archived' }),
});
if (errors) return <ErrorList errors={errors} />;
if (!projects || projects.length === 0) return empty;
return (
<nav className={twMerge('flex flex-col w-full', className)}>
{projects.map(({ id, name }) => (
<ProjectListItem
activeClassName={activeItemClassName}
href={itemHref || '/app/projects/:projectId'}
className={itemClassName}
id={id}
key={id}
name={name}
/>
))}
</nav>
);
};

I ignored a TypeScript error from the line declaring <ProjectList>. I believe this is an issue with React types for async components, as the error doesn't make sense to me, and everything works as expected. There are no runtime errors.

<ProjectListItem>, on the other hand, is a Client Component, as it uses useIsPathActive(), a small utility I built that uses Next's usePathname() to return a boolean value signaling if a URL path is active, i.e., if it exists in the pathname.

We use that to highlight the current viewed project in <MainMenu>.

Here's the entire code for it:

ProjectListItem.tsx

'use client';
import 'client-only';
import Link from 'next/link';
import { twMerge } from 'tailwind-merge';
import { ClassNamePropsOptional } from '@/features/shared/ui/ClassNameProps';
import { useIsPathActive } from '@/features/shared/routing/useIsPathActive';
interface ProjectListItemProps extends ClassNamePropsOptional {
readonly activeClassName?: string;
readonly href: string;
readonly id: string;
readonly name: string;
}
export const ProjectListItem = ({
activeClassName,
className,
href,
id,
name,
}: ProjectListItemProps) => {
const _href = href.replace(':projectId', id);
const isActive = useIsPathActive(_href);
return (
<Link
href={_href}
className={twMerge(
'flex grow items-center rounded-none lg:rounded-md py-2.5 text-base lg:text-sm text-gray-600 hover:bg-gray-200 border-b lg:border-b-0',
className,
isActive && activeClassName,
)}
>
{name}
</Link>
);
};

getProjects()

Here's the entire code for getProjects(), extracted from src/features/app/projects/data-access/ProjectsDataAccess.ts:

ProjectsDataAccess.ts

'use server';
import 'server-only';
import { prisma } from '@/features/shared/data-access/prisma';
import {
ServerResponse,
createServerErrorResponse,
createServerSuccessResponse,
} from '@/features//shared/data-access/ServerResponse';
import { genericAwareOfInternalErrorMessage } from '@/features/shared/ui/error/errorMessages';
import { getServerSideUser } from '@/features/app/users/data-access/UsersDataAccess';
export const getProjects = async ({ isArchived }: { isArchived?: boolean } = {}) => {
try {
const { id: authorId } = await getServerSideUser();
const result = await prisma.project.findMany({
where: { authorId, archivedAt: isArchived ? { not: null } : null },
orderBy: isArchived ? { archivedAt: 'desc' } : { createdAt: 'asc' },
});
return createServerSuccessResponse(result);
} catch (error) {
console.error(error);
// return a friendly error message instead of the (unknown) real one.
return createServerErrorResponse<ProjectDto[]>(genericAwareOfInternalErrorMessage);
}
};

It's a straightforward database query using Prisma and some additional error-handling logic.

<ProjectPage>

Project Page
Project Page on iPhone
Project Page - OpenTask
Project Page on iPad

File: src/app/app/projects/[projectId]/page.tsx

The <ProjectPage> lists all the project's tasks, a three-dot menu, and an Add task button.

Here's the entire code for it:

page.tsx

import { NoTasksInProject } from '@/features/app/projects/ui/NoTasksInProject';
import { ProjectPageHeader } from '@/features/app/projects/ui/ProjectPageHeader';
import { AddTask } from '@/features/app/tasks/ui/AddTask';
import { TaskList } from '@/features/app/tasks/ui/TaskList';
import { TaskForm } from '@/features/app/tasks/ui/TaskForm';
interface ProjectPageProps {
readonly params: { readonly projectId: string };
}
export default function ProjectPage({ params: { projectId } }: ProjectPageProps) {
return (
<>
<ProjectPageHeader id={projectId} />
<NoTasksInProject id={projectId} />
<TaskList byProject={projectId} only="incomplete" />
<AddTask containerClassName="my-8" projectId={projectId}>
<TaskForm
className="rounded-md bg-gray-100 px-2 py-6 sm:px-6 mt-4"
projectId={projectId}
startOnEditingMode
/>
</AddTask>
<TaskList byProject={projectId} only="completed" />
</>
);
}

Even though the <ProjectPage> Server Component is synchronous, <ProjectPageHeader>, <NoTasksInProject>, and <TaskList> are all async Server Components fetching data.

To avoid the layout shift issue, I want to display this page as a whole when all those async components are ready instead of displaying them individually as each one gets ready.

To do that, I could wrap everything in <Suspense>. However, when we want to wrap an entire page in <Suspense>, we can leverage App Router's loading.tsx file convention.

By creating a loading.tsx alongside a page.tsx file, Next.js automatically wraps the page.tsx content in <Suspense>, passing the component exported from loading.tsx as its fallback. That means we don't need to use <Suspense> in page.tsx.

Here's the entide code for <ProjectPageLoading>, located at src/app/app/projects/[projectId]/loading.tsx (alongside page.tsx):

loading.tsx

import { ProjectPageSkeleton } from '@/features/app/projects/ui/ProjectPageSkeleton';
export default function ProjectPageLoading() {
return <ProjectPageSkeleton className="mt-8" ssrOnly="Loading project..." />;
}

Sweet, isn't it?

When we navigate between projects that are not cached, we see <ProjectPageSkeleton> while those components get ready:

Project Page Skeleton
Project Page Skeleton on iPhone
Project Page Skeleton -
OpenTask
Project Page Skeleton on iPad
Listing the project's tasks

<ProjectPage> renders two <TaskList> components, one for pending tasks, and another for completed tasks.

To understand why we need that, let's understand the user experience requirements for this page.

To keep users focused on pending tasks, we render them first, ordered by the date they were created, from oldest to newest (createdAt: 'asc'). Allowing users to reorder tasks by dragging and dropping them to meet their changing priorities would be a nice future enhancement.

Below the list of pending tasks, we want to render an Add task button, making it convenient for users to create tasks.

However, we also want to allow users to easily reach their completed tasks for future reference or revert them to pending whenever necessary. Since that's a low-priority use case, we want to render that list below our Add task button, and we want it ordered by the date they were completed, from newest to oldest (completedAt: 'desc').

Those orderings are sensible defaults implemented in the getTasks() function, implemented in TasksDataAccess.ts and called by <TaskList> (further explained).

I designed a flexible yet simple <TaskList> component to meet those UX requirements. All we have to do is pass only="pending" or only="completed" to it.

Since we order those two task lists differently, we have to render two separate lists in <ProjectPage>.

You can play with it by creating and completing tasks to see how they get ordered.

The only filter is optional, so we can render a list of pending and completed tasks if we have a use case for that (we currently don't do that in OpenTask). We could only order the whole list by a single field, though.

Task pages and components

Routing module: src/app/app/tasks
Feature module: src/features/app/tasks

Let's now move to the implementation of the task pages.

<TaskList> and <TaskListItem>

Since we've just seen how <ProjectPage> renders <TaskList>, let's see how we implement it.

File: src/features/app/tasks/ui/TaskList.tsx

File: src/features/app/tasks/ui/TaskListItem.tsx

<TaskList> and <TaskListItem> are very similar to <ProjectList> and <ProjectListItem>. <TaskList> is a full-stack component, too.

We've just seen how easy it is to reuse and compose <TaskList> in the <ProjectPage>, and we'll see how we do that in the <TodayPage> soon.

We expose a clean API for consumer components to filter what task data they want, just like we do with <ProjectList>.

<TaskList> exposes a richer API with more filtering options, and it uses the getTasks() function to fetch tasks.

getTasks() receives an object of type GetTasksParams:


export interface GetTasksParams {
readonly byProject?: string;
readonly dueBy?: Date;
readonly dueOn?: Date;
readonly only?: 'completed' | 'incomplete';
readonly onlyProject?: 'active' | 'archived';
readonly orderBy?: 'completedAtAsc' | 'completedAtDesc' | 'createdAtAsc' | 'createdAtDesc';
}

<TaskList> extends that interface to allow all those filtering options.

Here's the entire code for it:

TaskList.tsx

'use server';
import 'server-only';
import { twMerge } from 'tailwind-merge';
import { ClassNamePropsOptional } from '@/features/shared/ui/ClassNameProps';
import { getServerSideUser } from '@/features/app/users/data-access/UsersDataAccess';
import { GetTasksParams, TaskDto, getTasks } from '../data-access/TasksDataAccess';
import { TaskListItem } from './TaskListItem';
import { ErrorList } from '@/features/shared/ui/error/ErrorList';
export interface TaskListProps extends GetTasksParams, ClassNamePropsOptional {
readonly children?: ({
list,
tasks,
}: {
readonly list: React.ReactNode;
readonly tasks: Array<TaskDto>;
}) => React.ReactNode;
}
/*
* I'm suppressing the following TypeScript error that seems to be an issue
* with React types for async components:
*
* "Type is referenced directly or indirectly in the fulfillment callback of its own 'then' method.ts(1062)"
*/
// @ts-ignore
export const TaskList = async ({ children, className, ...rest }: TaskListProps) => {
const [{ timeZone }, { data: tasks, errors }] = await Promise.all([
getServerSideUser(),
getTasks(rest),
]);
if (errors) return <ErrorList errors={errors} />;
let list = null;
if (tasks && tasks.length > 0) {
list = (
<div className={twMerge('flex flex-col', className)}>
{tasks.map((task) => (
<div key={task.id} className="flex mb-4 last:mb-0">
<TaskListItem
completedAt={task.completedAt}
description={task.description || ''}
dueDate={task.dueDate}
id={task.id}
key={task.id}
name={task.name}
timeZone={timeZone}
/>
</div>
))}
</div>
);
}
return typeof children === 'function' ? children({ list, tasks: tasks ?? [] }) : list;
};

We ignore the same TypeScript error as in <ProjectList>.

Notice that <TaskList> supports receiving an optional children prop of a custom function type.

When we pass such a function as children to <TaskList>, it calls it passing two arguments, list: React.ReactNode and tasks: Array<TaskDto>. Instead of returning the task list JSX, it passes that JSX to the function alongside an array of tasks and returns the function call result.

That means the function passed as children must return a valid React.ReactNode object.

As we'll see soon, we leverage that API in the <TodayPage>.

<TaskListItem>, on the other hand, is a Shared Component, as it doesn't consume any server or client-only APIs.

Here's the entire code for it:

TaskListItem.tsx

import Link from 'next/link';
import { sanitize } from 'isomorphic-dompurify';
import { utcToZonedTime } from 'date-fns-tz';
import { CalendarEventIcon } from '@/features/shared/ui/icon/CalendarEventIcon';
import { ClassNamePropsOptional } from '@/features/shared/ui/ClassNameProps';
import { TaskCheck } from './TaskCheck';
import { TaskCheckSize } from './TaskCheckSize';
import { twJoin, twMerge } from 'tailwind-merge';
import { formatTaskDueDate } from './formatTaskDueDate';
export interface TaskListItemProps extends ClassNamePropsOptional {
readonly completedAt: Date | null | undefined;
readonly description: string;
readonly dueDate: Date | null | undefined;
readonly id: string;
readonly name: string;
readonly timeZone: string;
}
export const TaskListItem = ({
className,
completedAt,
description,
dueDate,
id,
name,
timeZone,
}: TaskListItemProps) => {
return (
<div
className={twMerge(
'flex grow py-4 border-y border-transparent hover:border-gray-100',
className,
)}
>
<TaskCheck
className="mt-0.25"
completedAt={completedAt}
size={TaskCheckSize.Medium}
taskId={id}
/>
<Link href={`/app/tasks/${id}`} className="flex grow text-left cursor">
<div className="ml-3 block">
<div
className={twJoin('text-sm text-gray-800', completedAt && 'line-through')}
dangerouslySetInnerHTML={{ __html: sanitize(name) }}
/>
{description && (
<div
className="mt-2 block w-[20rem] overflow-hidden text-ellipsis whitespace-nowrap text-xs text-gray-400 md:w-[26rem] lg:w-[40rem]"
dangerouslySetInnerHTML={{ __html: sanitize(description) }}
/>
)}
{dueDate && (
<div className="flex mt-2">
<CalendarEventIcon className="fill-gray-400" width="0.875rem" height="0.875rem" />
<p className="text-xs text-gray-400 ml-1">
{formatTaskDueDate(
utcToZonedTime(dueDate, timeZone),
utcToZonedTime(new Date(), timeZone),
)}
</p>
</div>
)}
</div>
</Link>
</div>
);
};

getTasks()

getTasks() is similar to getProjects(), just a little more elaborated to support more filtering options.

<AddTask>, <NewTaskDialogPage>, and <NewTaskPage>

<ProjectPage> renders <AddTask>, which renders the Add task button.

The file for <AddTask> is located at src/features/app/tasks/ui/AddTask.tsx.

When we click the Add task button on small screens (width < 768), we navigate to /app/tasks/new. We have an Intercepting Route set up for it that renders <NewTaskDialogPage>, located at src/app/app/@dialog/(.)tasks/new/page.tsx:

New Task Dialog Page
New Task Dialog Page on iPhone

Here's the entire code for it:

page.tsx

import { Dialog } from '@/features/shared/ui/dialog/Dialog';
import { RouterActions } from '@/features/shared/routing/RouterActions';
import { TaskForm } from '@/features/app/tasks/ui/TaskForm';
interface NewTaskDialogPageProps {
readonly searchParams: { readonly projectId: string };
}
export default function NewTaskDialogPage({ searchParams: { projectId } }: NewTaskDialogPageProps) {
return (
<Dialog defaultOpen routerActionOnClose={RouterActions.BackAndRefresh}>
<TaskForm projectId={projectId} startOnEditingMode taskNameClassName="text-2xl" />
</Dialog>
);
}

Does it remind you something? Yep, it's just like <NewProjectDialogPage>.

If we refresh the browser while at /app/tasks/new, we render a regular route component too, <NewTaskPage>, located at src/app/app/tasks/new/page.tsx:

New Task Page
New Task Page on iPhone

Here's the entire code for it:

page.tsx

import { TaskForm } from '@/features/app/tasks/ui/TaskForm';
interface NewTaskPageProps {
readonly searchParams: { readonly projectId: string };
}
export default function NewTaskPage({ searchParams: { projectId } }: NewTaskPageProps) {
return (
<div className="flex flex-col mt-10">
<h1 className="mb-6 text-xl text-gray-800">Create task</h1>
<TaskForm projectId={projectId} startOnEditingMode taskNameClassName="text-2xl" />
</div>
);
}

That, too, looks a lot like <NewProjectPage>.

However, when we're on larger screens (width >= 768), we render the <TaskForm> in place:

Project Page with New Task Form -
OpenTask
Project Page with New Task Form on iPad

Rendering <TaskForm> in place on large screens results in a more fluid UX, allowing users to get instant feedback by seeing created tasks rendered in the task list.

That's how Todoist does it, and I wanted to try that even though it's more complex to implement. Since this is an engineering study, and OpenTask is pretty simple, I liked the idea of facing this problem.

We pass <TaskForm> as <AddTask>'s children to allow it to render <TaskForm> in place.

Let's recap how <ProjectPage> renders <AddTask>:

page.tsx

<AddTask containerClassName="my-8" projectId={projectId}>
<TaskForm
className="rounded-md bg-gray-100 px-2 py-6 sm:px-6 mt-4"
projectId={projectId}
startOnEditingMode
/>
</AddTask>

That's another example of Interleaving Server and Client Components: <AddTask> is a Client Component that receives <TaskForm> Server Component as children and renders it.

Here's the entire code for <AddTask>, except for a comment that I'll reproduce below:

AddTask.tsx

'use client';
import 'client-only';
import { Fragment } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useWindowSize } from 'usehooks-ts';
import { Transition } from '@headlessui/react';
import { twJoin, twMerge } from 'tailwind-merge';
import { ClassNamePropsOptional } from '@/features/shared/ui/ClassNameProps';
import { ChildrenProps } from '@/features/shared/ui/ChildrenProps';
import { buttonLinkClassName } from '@/features/shared/ui/control/button/buttonClassName';
import { PlusSignalIcon } from '@/features/shared/ui/icon/PlusSignalIcon';
export interface AddTaskProps extends ChildrenProps, ClassNamePropsOptional {
readonly containerClassName?: string;
readonly defaultDueDate?: 'today';
readonly projectId?: string;
}
export const AddTask = ({
children,
className,
containerClassName,
defaultDueDate,
projectId,
}: AddTaskProps) => {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const { width } = useWindowSize();
const isAddingTask = () => searchParams.get('newTask') !== null;
const onAddTask = () => {
if (width >= 768) {
router.replace(`${pathname}?newTask=true`);
} else {
const newTaskSearchParams = new URLSearchParams();
if (defaultDueDate === 'today') {
newTaskSearchParams.set('defaultDueDate', 'today');
}
if (projectId) newTaskSearchParams.set('projectId', projectId);
router.push(`/app/tasks/new?${newTaskSearchParams.toString()}`);
}
};
return (
<div className={twJoin(containerClassName)}>
<Transition
show={!isAddingTask()}
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 -translate-y-[50px]"
enterTo="opacity-100 translate-y-0"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 -translate-y-[50px]"
>
<button
onClick={onAddTask}
className={twMerge(buttonLinkClassName, 'group py-4 flex-row self-start', className)}
>
<PlusSignalIcon
width="1.25rem"
height="1.25rem"
className="fill-gray-600 mr-1 group-hover:fill-green-600"
/>
Add task
</button>
</Transition>
<Transition
show={isAddingTask()}
as="div"
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-[50px]"
enterTo="opacity-100 translate-y-0"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-[50px]"
>
{children}
</Transition>
</div>
);
};

We lift newTask=true state to the URL instead of using useState() to make it possible to have a cancel button in <TaskForm> that removes that state from the URL, which makes <AddTask> to stop rendering <TaskForm>.

That effectvely allows <TaskForm> to communicate with <AddTask>.

We pass <TaskForm> as children to <AddTask>. So, <TaskForm> cannot pass props to <AddTask> to tell it to stop rendering itself.

And since <TaskForm> is a Server Component, we cannot clone it in <AddTask> to pass a function to it to be called when clicking cancel.

We cannot pass functions from Client Components to Server Components.

We only have to remove newTask=true state from the URL when clicking the cancel button.

Here's how we do that:

TaskFormFields.tsx

...
import { usePathname } from 'next/navigation';
...
const pathname = usePathname();
...
onClick={() => {
router.replace(pathname);
}}
...

I extracted the code above from <TaskFormFields>, a Client Component rendered by <TaskForm>, further explained.

We use the URL as a shared global state.

<AddTask> listens to that URL change, rerenders, and this time its local isAddingTask() function returns false, which makes it stop rendering <TaskForm>.

That works well in many use cases, but this one feels a bit hacky. The use case is tricky, though.

I'd love to solve it in a better way. If you have an idea, please share it in the discussions, comments, or ping me on X.

Before moving to <TaskForm>, let's look into <TaskDialogPage> and <TaskPage>.

<TaskDialogPage> and <TaskPage>

When we click a <TaskListItem> in the <ProjectPage>, we navigate to /app/tasks/[taskId]. We have an Intercepting Route set up for it that renders <TaskDialogPage>:

Task Dialog Page
Task Dialog Page on iPhone
Task Dialog Page -
OpenTask
Task Dialog Page on iPad

Here's the entire code for <TaskDialogPage>, except for a comment that I reproduce below:

page.tsx

import { Suspense } from 'react';
import { DeleteIconButton } from '@/features/shared/ui/control/button/DeleteIconButton';
import { Dialog } from '@/features/shared/ui/dialog/Dialog';
import { RouterActions } from '@/features/shared/routing/RouterActions';
import { DeleteTaskAlertDialog } from '@/features/app/tasks/ui/DeleteTaskAlertDialog';
import { TaskForm } from '@/features/app/tasks/ui/TaskForm';
import { TaskFormSkeletonSkeleton } from '@/features/app/tasks/ui/TaskFormSkeleton';
interface TaskDialogPageProps {
readonly params: { readonly taskId: string };
}
export default function TaskDialogPage({ params: { taskId } }: TaskDialogPageProps) {
const deleteTaskDialog = (
<DeleteTaskAlertDialog
id={taskId}
routerActionOnSubmitSuccess={RouterActions.BackAndRefresh}
trigger={<DeleteIconButton className="mr-2" />}
/>
);
return (
<Dialog
defaultOpen
headerButtons={deleteTaskDialog}
routerActionOnClose={RouterActions.BackAndRefresh}
>
<Suspense fallback={<TaskFormSkeletonSkeleton className="mt-6" ssrOnly="Loading task..." />}>
<TaskForm taskId={taskId} taskNameClassName="text-2xl" />
</Suspense>
</Dialog>
);
}

We have to use RouterActions.BackAndRefresh to force a call to router.refresh() after navigating back to the previous route, which is not an Intercepting Route, so router.refresh() works.

We need that because we cannot call revalidatePath() or revalidateTag() from Parallel Routes or Intercepting Routes like <TaskDialogPage>, due to a well-known App Router bug I describe in a following section as well as comments in the <TaskFormTextFields> and <TaskCheck> source code.

So if we go back without calling router.refresh(), the updates we make in tasks using <TaskForm> will not be reflected in the UI.

If we refresh the browser while at /app/tasks/[taskId], we render a regular route component <TaskPage>, located at src/app/app/tasks/page.tsx:

Task Page
Task Page on iPhone
Task Dialog Page - OpenTask
Task Page on iPad

Here's the entire code for it:

page.tsx

import { TaskForm } from '@/features/app/tasks/ui/TaskForm';
interface TaskPageProps {
readonly params: { readonly taskId: string };
}
export default function TaskPage({ params: { taskId } }: TaskPageProps) {
return <TaskForm className="mt-10" taskId={taskId} taskNameClassName="text-2xl" />;
}

These pages render <TaskForm>, which display tasks and allow users to create and edit them, too, as we see next.

<TaskForm>

File: src/features/app/tasks/ui/TaskForm.tsx

By now, you might have noticed how flexible, reusable, and composable <TaskForm> is.

We use <TaskForm> to create, edit, and display tasks.

We render it in <NewTaskDialogPage>, <NewTaskPage>, <TaskDialogPage>, <TaskPage>, <ProjectPage> (to pass it as children of <AddTask>), and <TodayPage> (which we'll see soon, but works the same as <ProjectPage>).

Even though <TaskForm> is considerably more complex than <ProjectForm>, because it has more fields, including a Date Picker and a Select, I wanted to implement it following the same patterns.

<TaskForm> is a Server Component that fetches task data when we're editing tasks, i.e., when we pass a taskId, just like <ProjectForm>.

But there's a bug in Next.js that prevents us from calling revalidateTag(), revalidatePath(), and router.refresh() in Server Actions triggered by Intercepting Routes—the routing breaks.

I could reproduce it and reported it here.

I could find more reports related to the same issue: #51310, #51714, #60814, #60815, and #60844.

When we call revalidateTag() or revalidatePath() in a Server Action from a regular route, which does work, React and App Router rerender the React component tree, starting from the top-level component, which is usually a Server Component, all the way down to leave components, which can be Server or Client Components. React preserves the client-side state when it does that, so the UI updates as expected, not like a hard refresh.

But we cannot do that from Intercepting Routes, like <TaskDialogPage>. So I couldn't implement <TaskForm> as I implemented <ProjectForm>.

I kept <TaskForm> as a Server Component to preserve the ability to fetch task data when editing tasks, making it as reusable and composable as <ProjectForm>.

But to work around that bug, I created <TaskFormFields>, a Client Component that handles all the form logic for <TaskForm>. We need a Client Component because we have to useState() to store the current task object, much like when building React apps without Server Components and Server Actions.

Because of that, the implementation of <TaskForm> + <TaskFormFields> is more complex and verbose than <ProjectForm>.

I also had to work around that bug in <TaskCheck>. That's the round button we use to complete tasks and revert them to pending.

The Next.js core team is doing incredible work with the new App Router. I'm confident they'll fix that bug as soon as possible.

The workaround I implemented is a temporary fix. Once that bug is fixed, I will refactor <TaskForm> and related components to leverage revalidateTag(), and I expect the code to be much simpler and cleaner.

Because of that, I won't explain the workaround here. It'd make this article longer to explain a fix I'll hopefully discard soon.

If you're facing the same problem and looking for a workaround, check the codebase to see how I implemented it. Let me know if you have any questions or suggestions.

createTask()

The createTask() Server Action is very similar to createProject() and is located at src/features/app/tasks/data-access/TasksDataAccess.ts.

The updateTask() Server Action is very similar to createTask().

<TodayPage>

Today Page
Today Page on iPhone
Today Page -
OpenTask
Today Page on iPad

File: src/app/app/today/page.tsx

The goal of <TodayPage> is to help users see what to work on today and manage their overdue tasks.

The code is similar to <ProjectPage>. It also renders <AddTask> and two <TaskList> components, one filtering pending tasks that are overdue, and another filtering pending tasks due today.

Here's the entire code for it:


import { Suspense } from 'react';
import { redirect } from 'next/navigation';
import { subDays } from 'date-fns';
import { ErrorList } from '@/features/shared/ui/error/ErrorList';
import { getProjects } from '@/features/app/projects/data-access/ProjectsDataAccess';
import { AddTask } from '@/features/app/tasks/ui/AddTask';
import { TaskForm } from '@/features/app/tasks/ui/TaskForm';
import { TaskList } from '@/features/app/tasks/ui/TaskList';
import { TaskListSkeleton } from '@/features/app/tasks/ui/TaskListSkeleton';
import { TodayPageHeader } from '@/features/app/today/ui/TodayPageHeader';
export default async function TodayPage() {
const { data: projects, errors } = await getProjects();
if (errors) return <ErrorList errors={errors} />;
if (!projects || projects.length <= 0) redirect('/app/onboarding');
const yesterday = subDays(new Date(), 1);
const today = new Date();
return (
<>
<TodayPageHeader />
<Suspense fallback={<TaskListSkeleton className="mt-3" ssrOnly="Loading tasks..." />}>
<TaskList dueBy={yesterday} only="incomplete" onlyProject="active">
{({ list: listOverdue, tasks: tasksOverdue }) => (
<>
{tasksOverdue.length > 0 && <p className="mb-4 text-xs font-semibold">Overdue</p>}
{listOverdue}
{tasksOverdue.length > 0 && <p className="mt-8 mb-4 text-xs font-semibold">Today</p>}
<TaskList dueOn={today} only="incomplete" onlyProject="active">
{({ list: listDueToday, tasks: tasksDueToday }) => (
<>
{listDueToday}
{tasksDueToday.length < 1 && (
<p className="mt-6 mb-6 text-sm font-medium text-gray-600">
No tasks due today. {tasksOverdue.length < 1 && 'Enjoy your day!'}
</p>
)}
</>
)}
</TaskList>
</>
)}
</TaskList>
<AddTask containerClassName="my-8" defaultDueDate="today">
<TaskForm
className="rounded-md bg-gray-100 px-2 py-6 sm:px-6 mt-4"
defaultDueDate={today}
startOnEditingMode
/>
</AddTask>
</Suspense>
</>
);
}

The first thing to notice is that we redirect users to /app/onboarding when they don't have any projects. That happens after their first sign-in.

As mentioned earlier, we pass a function as children to <TaskList> to get the JSX of the task list and the filtered task array.

We have to do that because, on this page, we render Overdue and Today titles under certain data conditions alongside other messages. That means we must access the array of overdue tasks and the tasks due today from the page component.

We also nest the <TaskList> component for tasks due today under the <TaskList> component for overdue tasks.

You might think there's something wrong, but let's understand the user experience requirements for this page to understand why we need that.

Use case A: The user has no projects (so no tasks)

In this case, we redirect them to /app/onboarding, where we clarify that they should create their first project.

Use case B: The user has no tasks overdue nor due today

We display No tasks due today. Enjoy your day! and the Add task button.

Today Page
Today Page on iPhone
Use case C: The user has tasks due today and no overdue tasks

We display tasks due today.

Today Page
Today Page on iPhone
Use case D: The user has tasks due today and overdue

We display an Overdue title, overdue tasks below it, then a Today title, and tasks due today below it.

Today Page
Today Page on iPhone
Use case E: The user has tasks overdue and no tasks due today

We display an Overdue title, the overdue tasks below it, then a Today title, and the message No tasks due today..

Today Page
Today Page on iPhone
Implementing the use cases

We have five different use cases for this page.

The code for both <TaskList> rendering, with one nested inside the other, might initially seem overly complex, but after a few minutes, you might realize it's pretty simple.

We need to render the <TaskList> for tasks due today inside the <TaskList> for overdue tasks because we need to know if there are any overdue tasks within the <TaskList> for tasks due today because we want to display a different message when there are no tasks due today nor overdue than when there are no tasks due today but there are overdue tasks.

In the first case, when no tasks are due today or overdue, we display No tasks due today. Enjoy your day!.

In the second case, when no tasks are due today, but there are overdue tasks, we display No tasks due today.. Notice that we omit the Enjoy your day! phrase.

That's a small UI detail, but I wanted to handle more elaborate use cases to see how flexible this new way of building React components is.

It's very flexible.

Sometimes, we'll have to develop different or novel solutions, as this is a new model and way to build React components.

To support this use case by fine-tuning the UI, we designed an even more flexible <TaskList> component that we can reuse and compose in different ways. At the same time, we don't have to worry about its data dependencies despite its rich data filtering API.

I'm happy with the result.

Instead of fine-tuning text messages, we could use graphics for each case or graphics alongside text.

The bottom line is that we can meet simple use cases and more elaborate ones.

The only downside for this solution is that we create a small network waterfall: only after the first <TaskList> component fetches data from the database and finishes rendering by calling the children function the nested <TaskList> component is executed, also fetching data from the database.

In this case, I don't see it as a big problem. It's not ideal, of course, but both components render on the server, and both database queries are fast. Also, Next.js caches data, so only after users mutate task data is the cache cleared, and those two queries execute again.

However, there might be cases where a database query is slow, and we want to run those two queries in parallel. Then, we need to come up with another solution or compromise something, perhaps a more straightforward UI. We are, after all, making trade-offs all the time, and this is no different.

Another simple solution for this use case would be fetching overdue tasks twice, one as part of rendering <TaskList> for overdue tasks, and another calling getTasks() directly from <TodayPage> and then using the result inside the <TaskList> rendering for tasks due today, like the following:


...
const yesterday = subDays(new Date(), 1);
const today = new Date();
const { data: overdueTasks, errors } = getTasks({
dueBy: yesterday,
only: 'incomplete',
onlyProject: 'active'
});
...
return (
...
<TaskList dueOn={today} only="incomplete" onlyProject="active">
{({ list: listDueToday, tasks: tasksDueToday }) => (
// use overdueTasks.length to handle the use cases
)}
</TaskList>
...
);
...

The upside of the above solution is that we no longer have a network waterfall; the downside is that we now query tasks three times instead of two. Trade-offs. It's a nice solution, though. I just wanted to try a more component-based one.

Not every implementation detail is covered

There are several small implementation details that I haven't covered here to avoid having an even longer article. But I invite you to browse the codebase. It is clean and easy to follow, except where I had to work around that Next.js bug, making the code complex and hard to follow.

Conclusion

The promises are delivered: we can now build hybrid web applications that bring the best of both worlds: the server and the client, beautifully interleaved in the same component tree.

We can design highly flexible, reusable, and composable React Server Components that fetch data server-side effortlessly without needing an API layer.

We don't need to implement a client-side state management solution anymore. We fetch data and render the UI in the same component with full-stack React components, and Next.js caches data for us.

We can throw these components in any React component tree without worrying about its data dependencies.

Thanks to Server Actions providing a seamless RPC implementation, we can mutate data by calling JavaScript functions without building an API layer and clear the cache effortlessly by calling revalidateTag(), revalidatePath(), or router.refresh().

Those benefits translate to less code, complexity, and bugs, with faster development cycles.

With <Suspense> and streaming server rendering, we can prevent long data requests from blocking the page rendering, improving the user experience.

The new Next.js App Router assembles those features to deliver a complete and cohesive solution for building modern applications, supporting many other features, including advanced routing, nested layouts, error handling, data caching, API endpoints, middleware, and more.

As mentioned, the App Router currently has an important bug preventing us from using revalidatePath(), revalidateTag(), and router.refresh() in Server Actions triggered by Parallel Routes or Intercepting Routes. We can work around it, but when we do so, we lose most of the benefits of this new paradigm, and the code becomes more complex and verbose.

Even though OpenTask is a simple app, it includes several non-trivial features, and building an MVP from the ground up still requires considerable time, effort, and expertise.

Nonetheless, the new React 18 and Next.js App Router features considerably improve the developer experience, allowing us to build complex apps much faster by abstracting complex problems into the framework and eliminating whole layers of application code while improving the user experience.

When necessary, we can still implement client-side data fetching, client-side state management, and server-side APIs, making the App Router incredibly flexible.

I'm excited to build MVPs and migrate existing apps to the new Next.js App Router. Let's get in touch. 🚀

Engineering improvements

We can make many engineering improvements at both the repository and application level.

The following are some initial thoughts:

Test suite

OpenTask has no tests, and I consider this its biggest engineering miss.

I postponed writing tests for the MVP because I used new tools and techniques, including React Server Components and Server Actions, and I needed to figure out what the codebase would look like. I also wanted to move fast while experimenting with those new features.

That made sense. But now I plan on adding a test suite, reevaluating tools and techniques to achieve an optimized way to test Next.js App Router apps, including React Server Components and Server Actions. Once I finish it, I'll post about adding a test suite to OpenTask.

Do you have a great way to test Next.js App Router apps? Please share it with us.

Improvements at the repository level

Improvements at the application level

  • Refactor <TaskForm> and related components once the Next.js bug with Intercepting Routes + revalidateTag() and revalidatePath() is fixed.
  • Log and observability services (e.g. Axiom, Sentry, Datadog).

Join the project

I'd love to foster a community around OpenTask where developers can help evolve the project through contributions for new features and bug fixes and, most importantly, share ideas, establish best practices, and discuss the future of React, Next.js, and related tools.

Join the project, contribute with issues and discussions, share your thoughts!

What do you think about the engineering of OpenTask? Do you like the codebase?


What about what you don't like?

Share your insights in the comments below. I'd love to hear your thoughts.

I'm using Giscus, so you can comment and give a thumbs up with your GitHub account. 😉

While you're here, please share this article. Your support means a lot to me!



Thanks for reading!





I incorporated generative AI tools into my workflow, and I love them. But I use them carefully to brainstorm, research information faster, and express myself clearly. It's not copy/paste in any way.

Building OpenTask with Next.js App Router and RSCs by Flavio Silva is licensed under a Creative Commons Attribution 4.0 International License.

Leave a comment using your GitHub account

© 2024 Flavio Silva