Logo

Next.js 13(app directory) + Storybook + 4-tier Architecture

a scalable and maintainable solution to combine new structure of next.js 13 with a component explorer in a modular way.

Next.js 13 has been in beta testing for a while and some of its features have become stable and production-ready. However, the “app” directory, which is the main difference between this version and the previous ones, is still not widely adopted by most Next.js projects. 

 

This new feature and the mental model it introduces will eventually replace the old “pages” directory, so it’s important to have a scalable structure for large projects.

 

In this article, I will show you how to organize the “app” directory according to the documentation and the Three-tier architecture, which can help you achieve modularity and scalability.

If you are not familiar with this architecture in React projects, please read my previous article first.

 

React 18 was introduced in 2020, but its main new features were not easily accessible until the official release of Next.js 13.

 

The main idea behind React 18 is to use less or even no JavaScript (server components), which is similar to what other JS UI frameworks like Svelte are doing. Another key feature is asynchronous (concurrent) rendering, which can improve performance significantly with streaming.

 

Next.js has a specific structure for routes, but it does not prescribe how to structure components, logic, or data fetching.

 

Inspired by this article, we can treat a full-stack Next.js app like a traditional GUI app. This can help us create a modular app with independent and testable layers.

 

app directory overview

 

 

Presentation Layer(components folder and all other page components)

 

presentation-layer.png

 

One of the concepts that the official documentation suggests is “colocating”. This means you can put components and pages in the same directory (/app directory), which was not possible with the pages directory before. If we combine this with the bottom-up approach that Storybook recommends, we can have this folder structure:

In this example, I used the src folder to store all the non-page UI components and the logic folder to store the application and business logic.

These folders can be moved from the app directory to the root of the project, as we used to do before.

 

Button-component-overview.png

 

Button.tsx is a presentation layer that receives all its data and functionality as props. This pure functional component can be easily tested with Storybook, a tool that lets me define all states(stories) for a component and use it as a visual test tool with loki. Loki takes screenshots of every component and compares them with previous builds on each merge. I also have a Cypress test for the click handler in the component. Finally, index.tsx is the root of the folder. This component lets me import my component with its folder name (import Button from “@/app/src/Atomic/Button”).

// Button.tsx

"use client"

import React from 'react';

export default function Button({
  children,
  bgColor = 'var(--color-primary)',
  handler,
}: {
  children?: React.ReactNode;
  bgColor?: string;
  handler?: () =>
    | Promise<void>
    | (() => void)
    | (() => Promise<boolean>)
    | (() => boolean);
}) {
  return (
    <button
      className={`text-center flex flex-row items-center justify-center min-w-fit min-h-fit h-[40px] w-[250px] rounded-3xl text-[var(--light-text)] text-[1.1rem] shadow-xl`}
      onClick={()=>handler?.()}
      style={{backgroundColor: bgColor}}
    >
      {children}
    </button>
  );
}

 

 

// Button.cy.tsx

import Button from './Button';

it('must call the passed handler function', () => {
  const handler = cy.stub().as('handler');
  cy.mount(<Button handler={handler}></Button>);
  cy.get('button').click()
  cy.get('@handler').should('be.calledOnce')
});

 

 

// Button.stories.tsx

import { ComponentMeta, ComponentStory } from '@storybook/react';

import Button from './Button';
import React from 'react';

// More on default export: https://storybook.js.org/docs/react/writing-stories/introduction#default-export
export default {
  title: 'Atomic/Button',
  component: Button,
} as ComponentMeta<typeof Button>;

// More on component templates: https://storybook.js.org/docs/react/writing-stories/introduction#using-args
const Template: ComponentStory<typeof Button> = (args) => <Button {...args} />;

export const Primary = Template.bind({});
// More on args: https://storybook.js.org/docs/react/writing-stories/args
Primary.args = {
  children: 'Click',
};

 

 

// index.tsx

import Button from './Button';
export default Button;

 

Let's take a look at another component:

 

Comments-component-overview.png

 

 

// Comments.tsx

"use client"

import React, { useState } from 'react';
interface CommentsData {
  id: string;
  content: string;
}
export default function Comments({ data }: { data: CommentsData[] }) {
  return <div>Comments</div>;
}

 

 

// loading.tsx

import React from 'react'

export default function Loading() {
  return (
    <div>loading</div>
  )
}

 

 

// error.jsx

'use client';

import React from 'react';

export default class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    // Update state so the next render will show the fallback UI.
    return { hasError: true };
  }
  componentDidCatch(error, errorInfo) {
    // You can also log the error to an error reporting service
    // logErrorToMyService(error, errorInfo);
  }
  render() {
    if (this.state.hasError) {
      // You can render any custom fallback UI
      return <h1>Something went wrong.</h1>;
    }
    return this.props.children;
  }
}

 

 

Following the file name convention suggested by the official documentation for segment routes, We have two new files: loading.tsx and error.tsx. These files are used as suspense and error boundaries. again we have index.tsx to make our imports shorter and also to call my business or application logic and combine all the other files (loading.tsx and error.tsx).

 

Finally Let's check a page component:

 

 

posts-page.png

 

 

// Post.tsx

import Comments from '../../src/components/Composite/Comments';
import Image from 'next/image';
import { PostType } from '@/types/data';
import React from 'react';

export default function Post({ data }: { data: PostType }) {

  return (
    <>
      <article className='flex flex-col items-start justify-start gap-5 md:w-[70%] mx-auto p-4 px-6'>
        <h1 className='text-[40px] font-bold'>{data?.attributes?.title}</h1>
        <h2 className='text-[#6a6969] text-2xl'>
          {data?.attributes?.subtitle}
        </h2>
        {data?.attributes?.cover?.data?.attributes?.url && (
          <Image
            src={data?.attributes?.cover?.data.attributes?.url}
            alt={data?.attributes?.cover?.data.attributes?.alternativeText}
            width={300}
            height={300}
            className='w-full object-contain'
          />
        )}
        <div
          aria-label='content'
          dangerouslySetInnerHTML={{
            __html: data?.attributes?.content.replaceAll(
              'cdn.mehdiabdi.info',
              'cdn.mehdiabdi.info'
            ),
          }}
          className='text-xl'
        ></div>
      </article>
      {/* <Comments /> */}
    </>
  );
}

 

// page.tsx

import Post from './Post';
import { PostType } from '@/types/data';
import cmsClient from '../../src/data/CMS'

export default async function Page({ params }: { params: { slug: string } }) {
  const postData = await cmsClient.getOnePost(params.slug)
  return <Post data={postData.data[0]} />;
}

export async function generateStaticParams() {
  const posts = await cmsClient.getAllPosts()
  return posts.data.map((post: PostType) => ({
    slug: post.attributes.slug,
  }));
}

 

This page has been implemented as a dynamic segment to create a unique page for each article.

That was the UI layer.

 

Now let’s look at another layer.

 

Logic Layer(application and business)

logic.png

 

This layer contains all the app services (such as authentication) and domain logic. This structure can be further categorized into two separate folders for client and server logic.

 

Data Layer

data-layer.png

 

 

// CMS.ts

import 'server-only';

class CMS {
  readonly cmsHost: string;
  static cmsClient = undefined as any;

  constructor(cmsHost: string) {
    // singleton
    if (CMS.cmsClient) {
      throw new Error(
        'an instance of this object has been created before(singleton error)'
      );
    }
    CMS.cmsClient = this;
    this.cmsHost = cmsHost;
  }

  async getAllPosts() {
    return await fetch(
      process.env.NEXT_PUBLIC_CMS_HOST +
        '/api/posts?sort[0]=id:desc&populate=*',
      {
        headers: { Authorization: `bearer ${process.env.CMS_API_TOKEN}` },
      }
    ).then((resp) => resp.json());
  }

  async getOnePost(slug: string) {
    return await fetch(
      process.env.NEXT_PUBLIC_CMS_HOST +
        `/api/posts?filters[slug][$eq]=${slug}`,
      { headers: { Authorization: `bearer ${process.env.CMS_API_TOKEN}` } }
    ).then((resp) => resp.json());
  }
}

export default new CMS(process.env.NEXT_PUBLIC_CMS_HOST);

 

 

Finally, the data layer is responsible for making connections to external APIs, databases, caches or any other persistence solution that you are using. This separation helps us to make this part totally independent and changeable with any future solution that we want to try, as long as it follows the same interface.

Here I have implemented a class with a singleton pattern to fetch data from my content management system.

That’s it. These are all the layers of a portfolio and blog website that is written with the new Next.js app directory combined with a component explorer and test tools. This structure makes it easier to maintain and update the website. It’s also possible to move the layers to other machines or separate them into smaller services in a microservice architecture that increases scalability.

 

These files belong to a live project called portfolio, which is also available as a public GitHub repository.

 

Thank you for reading this article to the end. I would appreciate your feedback and thoughts on this article.