Building OpenTask with Next.js App Router and RSCs
Table of Contents
- TLDR
- Introduction
- React Server Components, Server Actions, and Suspense
- Building an application to test it all
- What application to build?
- Introducing OpenTask
- Technology Stack
- Development methodology
- Sitemap
- OpenTask's application architecture
- OpenTask's implementation
- Database
- <RootLayout>
- Marketing pages and components
- Authentication pages and components
- <AppLayout>
- <MainMenu>
- <MainMenuDialogPage>
- Onboarding page
- Project pages and components
- Task pages and components
- <TodayPage>
- Use case A: The user has no projects (so no tasks)
- Use case B: The user has no tasks overdue nor due today
- Use case C: The user has tasks due today and no overdue tasks
- Use case D: The user has tasks due today and overdue
- Use case E: The user has tasks overdue and no tasks due today
- Implementing the use cases
- Not every implementation detail is covered
- Conclusion
- Engineering improvements
- Join the project
- Related posts
- Interesting links
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.
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
- Date Picker: react-day-picker;
- Date manipulation: date-fns;
- Time zone support: date-fns-tz;
- Text editing: react-contenteditable;
- Sanitization: isomorphic-dompurify;
- Data schema declaration and validation: zod;
- Unique identifiers: cuid2;
- User-agent parsing: ua-parser-js;
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:
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:
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.tsxsrc/app/(marketing)/page.tsxsrc/app/(marketing)/about/page.mdxsrc/app/(marketing)/features/page.tsxsrc/app/(marketing)/pricing/page.tsxsrc/app/(marketing)/privacy/page.mdxsrc/app/(marketing)/terms/page.mdx
The following is the folder structure of the src/features/marketing/
business feature module:
shared/ui/Footer.tsxshared/ui/Header.tsxshared/ui/HeroCopy.tsxshared/ui/HeroHeading.tsxshared/ui/MainMenu.tsxshared/ui/MainMenuMobile.tsxshared/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>
:
<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
File: src/app/(marketing)/page.tsx
Here's the entire code for the landing page and related components:
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.tsxsrc/app/auth/layout.tsxsrc/app/auth/sign-in/page.tsxapp/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.tsdata-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>
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:
And here's the entire code for <OAuthProviderButton>
:
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:
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:
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>
:
<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.
Since BeforeInstallPromptEvent.prompt()
is unavailable on iOS, we instruct iOS users to add it to the home screen.
We use localStorage to prevent showing this dialog more than once for the same device.
<MainMenu>
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:
We'll look into <ProjectList>
moving forward.
<MainMenuDialogPage>
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:
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
.
Here's the entire code for it:
Notice we don't wrap <MainMenu>
in a <Dialog>
here.
Onboarding page
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:
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
:
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:
<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:
Here's the entire code for it:
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:
Clicking Edit project
opens the <EditProjectDialogPage>
:
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
:
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
.
Here's the entire code for it:
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:
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:
<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
:
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:
Here's the entire code for it:
We can use the switch component to navigate between active (/app/projects/active
) and archived projects (/app/projects/archived
).
Here's the <ArchivedProjectsPage>
:
And here's the entire code for it:
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>
:
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:
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:
getProjects()
Here's the entire code for getProjects()
, extracted from src/features/app/projects/data-access/ProjectsDataAccess.ts
:
It's a straightforward database query using Prisma and some additional error-handling logic.
<ProjectPage>
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:
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
):
Sweet, isn't it?
When we navigate between projects that are not cached, we see <ProjectPageSkeleton>
while those components get ready:
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:
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:
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
:
Here's the entire code for it:
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
:
Here's the entire code for it:
That, too, looks a lot like <NewProjectPage>
.
However, when we're on larger screens (width >= 768
), we render the <TaskForm>
in place:
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>
:
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:
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:
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>
:
Here's the entire code for <TaskDialogPage>
, except for a comment that I reproduce below:
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
:
Here's the entire code for it:
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>
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.
Use case C: The user has tasks due today and no overdue tasks
We display tasks due today.
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.
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.
.
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
- Husky to run Git hooks for ESLint, Prettier, etc.
- Dependabot (once we have a test suite).
- Bundle size analyzer.
- Automated Web Vitals.
Improvements at the application level
- Refactor
<TaskForm>
and related components once the Next.js bug with Intercepting Routes +revalidateTag()
andrevalidatePath()
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!
Related posts
- React Server Components and a new hybrid web app model
- Nexar: application architecture for Next.js App Router apps
- What is software architecture?
Interesting links
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.