I'm available to deliver your next SaaS MVP in 2-8 weeks, full-stack with the new Next.js App Router. Let's build your SaaS product.

OpenTask: A Next.js App Router Case Study

Learn how I built OpenTask, a free, open-source, fully functional, and responsive PWA MVP, using the new Next.js App Router, React Server Components, Server Actions, Suspense, Tailwind CSS, Radix, Supabase, and Prisma.

Flavio Silva
Flavio SilvaFebruary 8, 2024
OpenTask: A Next.js App Router Case Study

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: a free, open-source, fully functional, and responsive PWA MVP.

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 case study, 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 decided to keep it simple and build a task management app that is fully functional and responsive. With a short timeline in mind, this was the perfect experiment.

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>
);
}