codersteps logo

How to set up and structure your full-stack Next.js application

Abdessamad Ely
Abdessamad Ely
Software Engineer
Reading time: 25 min read  •  Published: in Next.js  •  Updated:

While I was writing another article, about JWT authentication with Next.js, I needed to create a fresh Next.js full-stack project.

So to make it easy in future articles, I thought it will be a good idea to make its own article and reference it, whenever I need too start with the boilerplate that we will build in this article.

Create a new Next.js project with TypeScript support

Lets first create a new project with NPX by running this command: npx create-next-app@latest --typescript nextjs_full_stack_boilerplate.

After the project is created successfully open it with your favorite editor, mine is vscode 😁. I always like to clean up the project by removing the default homepage content and its related CSS module styles.

I also like to clean up the README.md file and use it as a TODO list and note-taking throughout the website development, as well as removing the .git directory to make a clean initial commit.

The last thing I like to do is to add a custom _document.tsx page, because I know I will need it in one way or another.

Clean up our Next.js project

As described in the previous section, let's start by cleaning up the project we just created, open the README.md file and replace its content with:

README.md
## About

A Next.js full-stack boilerplate with TypeScript, Tailwind CSS, and Prisma.js for your future full-stack apps.

## TODO

- [x] Create a new Next.js app
- [-] Install and configure Tailwind CSS
- [-] Set up Typesafe environment variables
- [-] Set up Prisma.js
- [-] Automatize Next.js api handlers & Structure next.js api handlers
- [-] Set up a Next.js api hello world middleware
- [-] Set up a Hello World MOBX store

As you can see I have cleaned it up and created a to-do list and a short description of the project, we will check a to-do item on each section.

Now let's open the pages/index.tsx file and clean it up like the following:

pages/index.tsx
import Head from "next/head";
import type { NextPage } from "next";

const Home: NextPage = () => {
  return (
    <>
      <Head>
        <title>Next.js Full-Stack Boilerplate</title>
        <meta
          name="description"
          content="A Next.js full-stack boilerplate with TypeScript, Tailwind CSS, and Prisma.js for your future full-stack apps."
        />
      </Head>

      <main>
        <h1>Next.js Full-Stack Boilerplate</h1>
      </main>
    </>
  );
};

export default Home;

Because we delete this import import styles from '../styles/Home.module.css' we will delete the unused file 'styles/Home.module.css'.

Because we will use Tailwind CSS in the next section, we will also clean up the styles/globals.css and just leave it empty for the moment. Also, you can delete public/vercel.svg because we're not going to use it.

Add custom `_document.tsx` file

I will give you the default _document.tsx I use when I start a fresh Next.js project. Create a new file at pages/_document.tsx and use the following code.

pages/_document.tsx
import Document, {
  Html,
  Head,
  Main,
  NextScript,
  DocumentContext,
} from "next/document";

class MyDocument extends Document {
  static async getInitialProps(ctx: DocumentContext) {
    const originalRenderPage = ctx.renderPage;

    // Run the React rendering logic synchronously
    ctx.renderPage = async () => {
      return originalRenderPage({
        // Useful for wrapping the whole react tree
        enhanceApp: (App) =>
          function enhanceApp(props) {
            return <App {...props} />;
          },
        // Useful for wrapping in a per-page basis
        enhanceComponent: (Component) => Component,
      });
    };

    // Run the parent `getInitialProps`, it now includes the custom `renderPage`
    const initialProps = await Document.getInitialProps(ctx);

    return initialProps;
  }

  render() {
    return (
      <Html>
        <Head>
          <meta charSet="utf-8" />
          <link rel="icon" type="image/svg+xml" href="favicon.svg" />
        </Head>
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    );
  }
}

export default MyDocument;

The getInitialProps is used to pass extra props to all your pages, something like auth information, etc.

You maybe noticed that I had added an SVG favicon cause, I like to use it instead of generating a .ico to avoid low quality and have more control over styling it when it comes to dark mode for example.

If you have a question about something or you want to learn more about the custom document, Next.js has great documentation you can learn more about how you can customize it a little further.

Adding Google Fonts using _document.tsx

Before I start developing any project I like to choose a font first, most of the time I use google fonts for that. They are free and load fast.

In this article, I will use Roboto for regular text, and Open Sans for titles as examples, but you may use the right fonts for your situation.

First lets visit Google Fonts, and lookup Roboto and select the 300,400,500 styles, then lookup Open Sans and choose 600,700,800 styles.

You can also select the italic styles for each style, after we selected both fonts that we decided to use, Google Fonts will generate for us the necessary tags we need to include inside our website.

It also generates the css rules that we should use to apply the appropriate font, I will copy the generated tags and I will past them inside the Head component inside our _document.tsx file.

To keep it clean, we will use it through a new Fonts component, so create a new component at components/common/Google/Fonts.tsx and add the following:

components/common/Google/Fonts.tsx
export default function Fonts() {
  return (
    <>
      <link rel="preconnect" href="https://fonts.googleapis.com" />
      <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
      {
        // eslint-disable-next-line @next/next/no-page-custom-font
        <link
          href="https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,600;0,700;0,800;1,600;1,700;1,800&family=Roboto:ital,wght@0,300;0,400;0,500;1,300;1,400;1,500&display=swap"
          rel="stylesheet"
        ></link>
      }
    </>
  );
}

You will get a warning when using fonts inside the _doucment file, that's why we ignored it using the //eslint-disable-next-line @next/next/no-page-custom-font comment.

I have tried to solve this warning but I found having fonts server-rendered works better for me.

If I find anything in the future, that may change my mind, I will for sure include it here and add an explanation for that.

If you already have you're preferred way when it comes to adding fonts to your application please use that.

Now, lets include it inside our custom document like so:

pages/_document.tsx
// import the component
import Fonts from "../components/common/Google/Fonts";

// change the old Head content
<Head>
    <meta charSet="utf-8" />
    <link rel="icon" type="image/svg+xml" href="favicon.svg" />
    <Fonts />
</Head>

It's time to try it out, we will add a simple paragraph to our home page, and add global styles inside the styles/globals.css file.

Go ahead and add a simple paragraph with some content, and add the following styles, then visit the homepage and check if the font styles were applied.

styles/globals.css
body {
    font-size: 16px;
    font-family: 'Roboto', sans-serif;
}

h1,
h2 {
    font-family: 'Open Sans', sans-serif;
}

Install and configure Tailwind CSS

Now that we have cleaned up our project, and added Google Fonts. It's time to add Tailwind CSS our framework of choice to style our website.

First, let's run npm install -D tailwindcss postcss autoprefixer to install the required packages by Tailwind CSS.

Then, we have to run npx tailwindcss init -p which will generate for us our tailwind.config.js and postcss.config.js.

Because tailwind now has jit (Just-in-Time) enabled by default we need to include the paths to our template files to the content property inside tailwind.config.js.

Open your tailwind.config.js and adjust it to look like the one bellow.

tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    './pages/**/*.{js,ts,jsx,tsx}',
    './components/**/*.{js,ts,jsx,tsx}',
  ],
  theme: {
    extend: {
      fontFamily: {
        sans: ['Roboto', 'sans-serif'],
        open: ['Open Sans', 'sans-serif'],
      },
    },
  },
  plugins: [],
}

As you can see we also included the Google Fonts, inside the themes extend the property, we override the sans font which is the default font, and we added the Open Sans font as open.

Now It's time to include Tailwind directives in our styles/globals.css, but before that let's change it to use sass instead.

Todo that rename the file styles/globals.css to styles/globals.scss, then change the import path inside the pages/_app.tsx file.

pages/_app.tsx
import '../styles/globals.scss'

Now run npm install -D sass to install sass, and update the styles/globals.scss file to match the following.

styles/globals.scss
@tailwind base;
@tailwind components;
@tailwind utilities;

Prettier is an important tool used to format our code, It's important to have a convention at the start of your project. So let's create a .prettierrc file with the following content.

.prettierrc
{
  "tabWidth": 2,
  "singleQuote": true,
  "trailingComma": "all",
  "semi": false
}

Please feel free to change it to suit your needs.

You can also learn more about Setting up Tailwind CSS with Next.js from its original authors.

To ensure our tailwindcss installation went well, let's add some classes to the home page H1 tag.

pages/index.tsx
// the before code
<main>
    <h1 className="text-3xl font-bold font-open">
        Next.js Full-Stack Boilerplate
    </h1>
</main>
// the after code

After doing the change run your server with npm run dev and open your app on your browser, you can use Chrome Elements Inspector to check the applied fonts.

Install and configure Prisma.js with Next.js

Prisma.js is a productive ORM for managing your database, I like to use it with PostgreSQL so in this section I will assume you already have Postgres installed on your system.

In case you prefer to use another type of database, please refer to Prisma.js documentation and lookup for Database Connectors to learn how to create a database connection for you special case.

But the cool thing about Prisma.js is having the same client API for all databases, you may find that a feature is supported in one but not in another but it won't make much difference in our case.

Installing and initializing Prisma.js

Without further ado, let's install prisma and @prisma/client by running npm install prisma --save-dev then npm install @prisma/client the Prisma CLI is installed as a dev dependency cause it's used only in development to generate types, push migration and so on.

After the installation is completed, let's run npx prisma init to make Prisma CLI generate for us the necessary files prisma/schema.prisma and .env with the necessary env variables.

Inside the schema.prisma file you can find the used provider which is Postgresql by default, here you need to change it if you're using a different database.

Inside the .env file we have to modify the DATABASE_URL and set the correct value for our dev environment.

For me, this is what my .env looks like after I have created a new database using createdb fullstack_app called fullstack_app with a default PostgreSQL port (5432), and no password.

.env
DATABASE_URL="postgresql://codersteps:@localhost:5432/fullstack_app?schema=public"

To verify that your connection is valid and working correctly you can run npx prisma db push this will try to connect to your database and sync the prisma schema, which is empty at the moment with the actual database structure.

I will also assume you have a little knowledge about how to connect to a PostgreSQL database, so you know how to create a new database and how to connect to it.

I like to always keep a dummy copy of my .env file as .env.example in order to track it with GIT, but make sure you don't put any sensitive information inside of it.

Also, make sure to add the .env file to your .gitignore just after "# local env files".

.gitignore
# local env files
.env
.env*.local

Create a shared Prisma Connection and use it

Let's create a new file for our database connection lib/database.ts which will export a new PrismaClient the first time, and reuse it whenever we use it.

lib/database.ts
import { PrismaClient } from '@prisma/client'

let prisma: PrismaClient

if (process.env.NODE_ENV === 'production') {
  prisma = new PrismaClient()
} else {
  if (!global.prisma) {
    global.prisma = new PrismaClient()
  }
  prisma = global.prisma
}

export default prisma

As you can see we declare a variable called prisma of type PrismaClient, if this file was imported on production we create a new instance assign it to the variable, and export it at the bottom.

When we're in development because Next.js do refresh on change and to avoid having many connections open we use a global variable to store our Prisma connection and use it as long as it is set.

Because we are using TypeScript we need to declare the global prisma property, otherwise, TypeScript will keep yelling at us.

Let's create a new type file at types/global.d.ts and add the following.

types/global.d.ts
import { PrismaClient } from '@prisma/client'

declare global {
  var prisma: PrismaClient | undefined
}

After extending the global object by adding a new prisma property the error should disappear from lib/database.ts.

Adding a simple User model and insert some data to it

To be able to show you how to use prisma.js with next.js in different situations, We will create a simple User table using Prisma schema syntax.

We will add a User model to the prisma/schema.prisma file with the user attributes, then we will push the changes to our database with the prisma db push command.

Let's open our schema file and append the following to it.

prisma/schema.prisma
model User {
  id        Int      @id @default(autoincrement())
  firstName String   @default("anonymous")
  lastName  String   @default("")
  bio       String   @default("")
  username  String   @unique
  password  String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

Prisma schema is straightforward, you can understand the model we just added only by reading it.

We have an auto-increment id, an optional firstName, lastName with default values, a unique username, a required password, and a createdAt, updatedAt.

Now that we have added the model, let's sync it with our database, go and run npx prisma db push.

Now if you open your database with your database client GUI, or through the CLI you should see a new User table with the exact columns that we described in our schema was created.

Seeding data some initial data to our database

Knowing how to seed data is pretty important, imagine we don't allow users to register, but we only allow admins to create new users.

In such a situation we will need to seed some admin users to start with, another situation is static data that won't change frequently like user roles.

You may have a Role table which will have for example three rows populated respectively with the values ADMIN, TEACHER, and STUDENT. Seeding such data when starting fresh makes sense.

Let's create a new seed.ts file inside the prisma directory, and use it to insert some users, because we will be dealing with inserting users passwords we need to hash them.

To do that we will use the bcrypt package, we will install it using npm install bcrypt because we use TypeScript we have to install its types as well with npm install @types/bcrypt --save-dev.

Now we are ready to create some users and use them to populate our User table, Let's write the code and explain afterward.

prisma/seed.ts
import bcrypt from 'bcrypt'
import { Prisma, PrismaClient } from '@prisma/client'

const db = new PrismaClient()

async function seed() {
  const usersCount = await db.user.count()
  if (usersCount === 0) {
    const users: Prisma.UserCreateManyInput[] = [
      {
        firstName: 'Leanne',
        lastName: 'Graham',
        bio: 'Pop culture fanatic. Freelance music lover. Unapologetic food fanatic. Bacon specialist.',
        username: 'Bret',
        password: bcrypt.hashSync(process.env.ADMIN_PASSWORD || 'admin', 10),
      },
      {
        firstName: 'Ervin',
        lastName: 'Howell',
        bio: 'Friendly twitter practitioner. Bacon lover. Reader. General social media specialist. Student.',
        username: 'Antonette',
        password: bcrypt.hashSync(process.env.ADMIN_PASSWORD || 'admin', 10),
      },
      {
        firstName: 'Clementine',
        lastName: 'Bauch',
        bio: 'Food lover. Twitter nerd. Internet evangelist. Alcohol enthusiast. Friendly explorer.',
        username: 'Samantha',
        password: bcrypt.hashSync(process.env.ADMIN_PASSWORD || 'admin', 10),
      },
    ]

    await db.user.createMany({
      data: users,
    })
  }
}

seed()
  .catch((e) => {
    console.error(e)
    process.exit(1)
  })
  .finally(async () => {
    await db.$disconnect()
  })

We will use the esbuild-register to execute this file, thus this file will run only once. First, we instantiated a new Prisma client then we created an async seed() function because all Prisma methods are asynchronous.

Because async functions return a Promise, we use the catch method to exit the process, and the finally method to close the connection in both cases success or failure.

We check if the User table is empty, if yes we go ahead and insert our users if no this means we already inserted them so we just ignore the insertion.

Because this file will be tracked with GIT, it's important not to add any sensitive data, that's why we used an environment variable to store our admin password ADMIN_PASSWORD.

It's time to execute the seed file, to do that first let's install the esbuild-register package with npm install esbuild-register --save-dev.

To let Prisma know how to execute our seed file we will add a new prisma property to our package.json file with the following value.

package.json
{
    ...
    "prisma": {
      "seed": "node --require esbuild-register prisma/seed.ts"
    },
    "scripts": {
        ...
    }
    ...
}

After telling Prisma to use the esbuild-register now we're ready to run our seed command: npx prisma db seed. Now if you check your User table content you should see that our users were added successfully.

Adding Prisma CLI commands to our NPM scrips

Because during our development, we will frequently execute the prisma db push, I find it useful to add it as an NPM script.

Open your package.json file and just after lint script you can add the following Prisma CLI-related scripts.

package.json
{
    ...
    "scripts": {
      ...
      "db:push": "prisma db push",
      "db:seed": "prisma db seed",
      "db:studio": "prisma studio",
      "prisma:generate": "prisma generate"
    },
    ...
}

Now instead of using npx prisma db push you can use npm run db:push or if you have yarn installed you use yarn db:push.

That was it for setting up Prisma.js with Next.js, but we will use it later to explain other concepts and demonstrate how you can use it with Next.js in different situations.

To learn more about Prisma.js and about the different ways to use Prisma.js with Next.js you can always check their awesome website and documentation.

Type safe Next.js environment variables

By now we have two environment variables ADMIN_PASSWORD and DATABASE_URL but down the road, you will have much more of them.

To avoid issues like typos and type mismatch, Its typing our environment variables will include them in the process.env autocompletion list.

Go ahead and open the types/global.d.ts file and modify it to match the following.

types/global.d.ts
import { PrismaClient } from '@prisma/client'

declare global {
  var prisma: PrismaClient | undefined

  namespace NodeJS {
    interface ProcessEnv {
      ADMIN_PASSWORD: string | undefined
      DATABASE_URL: string | undefined
    }
  }
}

Now if tried to access the properties of the process.env you should see that our two variables were included.

Automatize Next.js api handlers

Before I decided to automize the Next.js API handlers, I was using it with the default structure, I was adding all my handlers inside a single file.

With time and while my project started to grow and have multiple API routes, especially when dealing with endpoints like api/users/[id].ts.

Where I have to handle requests for GET, POST, PUT and DELETE HTTP verbs inside the same file, and if you use the default Next.js handlers you will have to map requests yourself.

While it's powerful and gives you control, with time you will have duplicated logic that can be abstracted and automatized.

And that's what I did, and because it worked well for me for a little while now, I thought to share it with you here, so you can use it as well.

Next.js crud API for User with Prisma.js

To be able to show you the benefit of automatizing Next.js api handlers, and help you understand what's going on, we will create a simple crud api without any automatization.

Create a new file at pages/api/users/[id].ts this endpoint will be able to handle the read, update, and delete using the user id.

Let's write the code first and test it, then I will try to explain anything that I see needs to be explained.

pages/api/users/[id].ts
import type { NextApiRequest, NextApiResponse } from 'next'
import { Prisma, User } from '@prisma/client'
import { ReasonPhrases, StatusCodes } from 'http-status-codes'
import db from '@/lib/database'

type Data = {
  data: User | null
  error: string | null
}

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<Data>,
) {
  const id = typeof req.query.id === 'string' ? parseInt(req.query.id, 10) : 0

  const userExists = (await db.user.count({ where: { id } })) > 0
  if (!userExists) {
    res
      .status(StatusCodes.NOT_FOUND)
      .json({ data: null, error: ReasonPhrases.NOT_FOUND })
  }

  if (req.method === 'GET') {
    const user = await db.user.findFirst({ where: { id } })
    res.status(StatusCodes.OK).json({ data: user, error: null })
    return
  }

  if (req.method === 'PUT') {
    const { firstName, lastName, bio } =
      req.body as Prisma.UserUncheckedUpdateInput

    const data: Prisma.UserUncheckedUpdateInput = {}

    if (typeof firstName === 'string') {
      data.firstName = firstName
    }
    if (typeof lastName === 'string') {
      data.lastName = lastName
    }
    if (typeof bio === 'string') {
      data.bio = bio
    }

    const updatedUser = await db.user.update({ data, where: { id } })
    res.status(StatusCodes.OK).json({ data: updatedUser, error: null })
    return
  }

  if (req.method === 'DELETE') {
    const deletedUser = await db.user.delete({ where: { id } })
    res.status(StatusCodes.OK).json({ data: deletedUser, error: null })
    return
  }

  res
    .status(StatusCodes.METHOD_NOT_ALLOWED)
    .json({ data: null, error: ReasonPhrases.METHOD_NOT_ALLOWED })
}

When dealing with HTTP status and error messages I always like to use this npm package http-status-codes go ahead and install it with npm install http-status-codes.

When our project starts to grow, import statements start to become a hustle, that's why I prefer to use aliases like import db from '@/lib/database which is much cleaner.

You can do the same by modifying your tsconfig.json file by adding the mentioned properties below, I have included some directories which we haven't created yet but we will soon.

tsconfig.json
  {
    "compilerOptions": {
      "baseUrl": ".",
      ...
      "incremental": true,
      "paths": {
        "@/app/*": ["app/*"],
        "@/components/*": ["components/*"],
        "@/core/*": ["core/*"],
        "@/hooks/*": ["hooks/*"],
        "@/lib/*": ["lib/*"],
        "@/middlewares/*": ["middlewares/*"],
        "@/pages/*": ["pages/*"],
        "@/store/*": ["store/*"],
        "@/types/*": ["types/*"]
      }
    },
    "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
    ...
  }

Let's create another file at pages/api/users/index.ts this endpoint will be able to handle the read all, and create a new user.

pages/api/users/index.ts
import type { NextApiRequest, NextApiResponse } from 'next'
import bcrypt from 'bcrypt'
import { Prisma, User } from '@prisma/client'
import { ReasonPhrases, StatusCodes } from 'http-status-codes'
import db from '@/lib/database'

type Data = {
  data: User | User[] | null
  error: string | null
}

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<Data>,
) {
  if (req.method === 'GET') {
    const users = await db.user.findMany()
    res.status(StatusCodes.OK).json({ data: users, error: null })
    return
  }

  if (req.method === 'POST') {
    const { firstName, lastName, bio, username, password } =
      req.body as Prisma.UserUncheckedCreateInput

    if (
      typeof username !== 'string' ||
      typeof password !== 'string' ||
      password.length < 6
    ) {
      res
        .status(StatusCodes.BAD_REQUEST)
        .json({ data: null, error: ReasonPhrases.BAD_REQUEST })
      return
    }

    const hashedPassword = bcrypt.hashSync(password, 10)
    const data: Prisma.UserUncheckedCreateInput = {
      username,
      password: hashedPassword,
    }

    if (typeof firstName === 'string') {
      data.firstName = firstName
    }
    if (typeof lastName === 'string') {
      data.lastName = lastName
    }
    if (typeof bio === 'string') {
      data.bio = bio
    }

    try {
      const createdUser = await db.user.create({ data })
      res.status(StatusCodes.OK).json({ data: createdUser, error: null })
    } catch (e) {
      res
        .status(StatusCodes.INTERNAL_SERVER_ERROR)
        .json({ data: null, error: ReasonPhrases.INTERNAL_SERVER_ERROR })
    }
    return
  }

  res
    .status(StatusCodes.METHOD_NOT_ALLOWED)
    .json({ data: null, error: ReasonPhrases.METHOD_NOT_ALLOWED })
}

For both api files pages/api/users/index.ts and pages/api/users/[id].ts we use the same principle we check for the request methods that we want to handle and handle them inside an if statement.

If no method matched we respond with 405 Method Not Allowed, and inside each handler, we import the Prisma client with import db from '@/lib/database' and we use it to perform the crud operations.

There's also a possibility to extract the logic inside the if statement to a new function like createUserHandler but it still doesn't solve our problem, which is having a consistent structure that we use again and again.

Refactoring and Automatizing our Users Next.js api

Now that we have something to work with, we will first create a helper which will take, a handler and an HTTP verb that the handler should handle and it will wire them up for us.

The final goal here is to be able to use our api endpoint file as follows.

pages/api/users/index.ts
import makeHandler from '@/core/make-handler'
import indexHandler from '@/app/users/index.handler'
import createHandler from '@/app/users/create.handler'

export default makeHandler([
  {
    method: 'GET',
    handler: indexHandler,
  },
  {
    method: 'POST',
    handler: createHandler,
  },
])

I think it looks much cleaner and you can easily reason about which part of your project you need to touch to achieve what you're trying to do.

If you are for example trying to fix or change something when creating a user, you will easily know from looking at the api endpoint file that you're looking for the app/users/create.handler.ts file.

Creating the makeHandler helper

Because this helper serves a core functionality I like to add it to a core directory, go ahead and create a new file at core/make-handler.ts

Inside the make-handler.ts file we will export a makeHandler function which will take as shown in the example before a method and a handler.

I think it's easier for you to understand if you see the code first, then any explanation will make sense better.

core/make-handler.ts
import { Prisma } from '@prisma/client'
import type { NextApiHandler } from 'next'
import { ReasonPhrases, StatusCodes } from 'http-status-codes'

type Data = {
  error: string
}

const makeHandler: (
  handlerOptions: {
    method: 'POST' | 'GET' | 'PUT' | 'PATCH' | 'DELETE'
    handler: NextApiHandler
  }[],
) => NextApiHandler<Data> = (handlerOptions) => {
  return async (req, res) => {
    const handlerOption = handlerOptions.find(
      (handlerOption) => handlerOption.method === req.method,
    )

    if (!handlerOption) {
      res.setHeader(
        'Allow',
        handlerOptions.map((handlerOption) => handlerOption.method),
      )
      res
        .status(StatusCodes.METHOD_NOT_ALLOWED)
        .json({ error: ReasonPhrases.METHOD_NOT_ALLOWED })
      return
    }

    try {
      await handlerOption.handler(req, res)
    } catch (e) {
      console.error(e)

      if (e instanceof Prisma.PrismaClientKnownRequestError) {
        if (e.code === 'P2002') {
          res.status(StatusCodes.BAD_REQUEST).json({
            error: ReasonPhrases.BAD_REQUEST,
          })
          return
        }

        if (e.code === 'P2025') {
          res.status(StatusCodes.NOT_FOUND).json({
            error: ReasonPhrases.NOT_FOUND,
          })
          return
        }
      }

      res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({
        error: ReasonPhrases.INTERNAL_SERVER_ERROR,
      })
    }
  }
}

export default makeHandler

We accept the handlerOptions param, which is an array of values, something like:

pages/api/users/index.ts
[
  {
    method: 'GET',
    handler: indexHandler,
  },
  {
    method: 'POST',
    handler: createHandler,
  },
]

When a request comes, we use the req.method to find the right handlerOption for the current request. In case no option was found we simply set a header for the allowed methods and send back a response with 405 Method Not Allowed.

If the request method is allowed, we call the provided handler for that method, we call it inside a try/catch to handle global exceptions like the 500 Internal Server Error, Prisma unique constraint, or Prisma required record was not found.

You can check the other Prisma.js Known Request Error also feel free to add any extra error handling check that may be useful for you situation.

Adding a global error handling like this, will save us from having unnecessary try{}catch{} inside our handlers which means less nesting.

That's it for our makeHandler helper, as you can see it's pretty straightforward and will help us write less duplicated code.

Refactor the api endpoint file to use the makeHandler

Let's refactor our users' api endpoints to use this new makeHandler helper, let's start by creating a file for each handler inside an app/users directory so they're grouped by domain.

When it comes to naming and structuring the api handlers, I simply keep them aligned with api endpoints, here's some example:

For users, we have the pages/api/users/index.ts which handles both the GET and the POST methods we will need two handlers, for this I will have them at app/users/index.handler.ts and app/users/create.handler.ts.

And for pages/api/users/[id].ts it handles the GET, PUT, and DELETE methods, for this we will use three handlers at app/users/show.handler.ts, app/users/update.handler.ts, and app/users/delete.handler.ts.

And so on, you can see the convention, you have a handler for each HTTP verb, and you name them like indexHandler which will fetch all records, showHandler which will fetch a single record.

I also like to have the .handler.ts at the end of the file, so it's easy to know the purpose of the file just from its file name suffix.

Maybe you noticed that we don't use indexUsersHandler but only indexHandler because we are already in the users' context, so just indexHandler makes sense.

Let's create the app/users/create.handler.ts file to handle creating new users.

app/users/create.handler.ts
import type { NextApiHandler } from 'next'
import bcrypt from 'bcrypt'
import { Prisma, User } from '@prisma/client'
import { ReasonPhrases, StatusCodes } from 'http-status-codes'
import db from '@/lib/database'

interface Data {
  error: string | null
  data: User | null
}

const createHandler: NextApiHandler<Data> = async (req, res) => {
  const { firstName, lastName, bio, username, password } =
    req.body as Prisma.UserUncheckedCreateInput

  if (
    typeof username !== 'string' ||
    typeof password !== 'string' ||
    password.length < 6
  ) {
    res
      .status(StatusCodes.BAD_REQUEST)
      .json({ data: null, error: ReasonPhrases.BAD_REQUEST })
    return
  }

  const hashedPassword = bcrypt.hashSync(password, 10)
  const data: Prisma.UserUncheckedCreateInput = {
    username,
    password: hashedPassword,
  }

  if (typeof firstName === 'string') {
    data.firstName = firstName
  }
  if (typeof lastName === 'string') {
    data.lastName = lastName
  }
  if (typeof bio === 'string') {
    data.bio = bio
  }

  const createdUser = await db.user.create({ data })
  res.status(StatusCodes.OK).json({ data: createdUser, error: null })
}

export default createHandler

Now let's create the app/users/index.handler.ts file to handle fetching all users.

app/users/index.handler.ts
import type { NextApiHandler } from 'next'
import { User } from '@prisma/client'
import { StatusCodes } from 'http-status-codes'
import db from '@/lib/database'

interface Data {
  data: User[]
}

const indexHandler: NextApiHandler<Data> = async (req, res) => {
  const users = await db.user.findMany()
  res.status(StatusCodes.OK).json({ data: users })
}

export default indexHandler

You can see that the logic is the same, the only difference is that we export a next.js api handler, and we only handle a single request per handler file.

Now it's time to change our api endpoint file to use our makeHandler the newly created handlers.

pages/api/users/index.ts
import makeHandler from '@/core/make-handler'
import indexHandler from '@/app/users/index.handler'
import createHandler from '@/app/users/create.handler'

export default makeHandler([
  {
    method: 'GET',
    handler: indexHandler,
  },
  {
    method: 'POST',
    handler: createHandler,
  },
])

You can use postman to test that it's still working as expected, by trying to create new users and fetch all users to see if they were added.

Now it's your time to refactor the other api endpoint, go ahead and create 3 more handlers under app/users and refactor pages/api/users/[id].ts to use them instead with the help of our makeHandler helper.

If you encounter any issues, you will find the final project at the end of this article, you can check it out and see what you did wrong.

Set up a Hello World Next.js api middleware

Next.js 12.2 has a _middleware.ts file that can be used to intercept the request-response lifecycle but this is not what I will teach you here.

But you can learn all about Next.js Middleware from it's original authors.

Sometimes we want to have a middleware that we can apply to a single api handler, like auth, csrf verification, or a similar type of middleware.

We will create a simple middleware we will call it hello_world and it will simply log a "Hello, World!" string to the console when the request has a sayHello=true cookie.

The idea here is to show you how you can create custom middleware, and apply them to our api handlers.

The main idea is to create a function that takes a handler as a param and returns a new handler, something like a higher-order function.

Let's first create a new type file types/next.d.ts and declare the NextApiMiddleware type which is simply a function that takes a next api handler and returns one.

types/next.d.ts
export declare type NextApiMiddleware = (
  handler: NextApiHandler,
) => NextApiHandler

Now, let's create a new file middlewares/hello-world.middleware.ts for our hello_world middleware.

middlewares/hello-world.middleware.ts
import type { NextApiMiddleware } from '@/types/next'

const helloWorld: NextApiMiddleware = (handler) => {
  return async (req, res) => {
    if (
      typeof req.cookies.sayHello === 'string' &&
      req.cookies.sayHello === 'true'
    ) {
      console.log('Hello, World!')
    }

    await handler(req, res)
  }
}

export default helloWorld

Now we will use it on the indexHandler inside the pages/api/users/index.ts endpoint.

pages/api/users/index.ts
...
import helloWorld from '@/middlewares/hello-world.middleware'

export default makeHandler([
  {
    method: 'GET',
    handler: helloWorld(indexHandler),
  },
  ...
])

Now if you use something like postman to send a request that has a Cookie header with the value sayHello=true and check your terminal a "Hello, World!" message should appear.

Set up a Hello World MOBX store with Next.js

Note: this section is under review.

Handling global state starts to be necessary when our application starts to grow, a common use case is sharing our auth user between our components.

There are many available solutions to deal with the global state, the most popular ones that I know of are Redux and MobX.

In this section, I would like to show you how to set up a simple MobX store to share a simple global state with a message property.

Using the same steps from this section, you can create other MobX stores to serve your needs cause the same principle applies here.

Let's start by installing the required dependencies to use MobX with React.js, by running npm install mobx mobx-react-lite.

Then let's create a new file for our store at store/hello-world.store.ts MobX uses a pattern called the Observer Pattern.

You can learn more about how Mobx works at their own website, they have a nice documentation for you to read.

Let's write our MobX Hello world store code.

store/hello-world.store.ts
import { makeAutoObservable } from 'mobx'

type Store = {
  message: string | null
  setMessage: (message: string) => void
}

const helloWorldStore = makeAutoObservable<Store>({
  message: null,
  setMessage(message) {
    this.message = message
  },
})

export default helloWorldStore

We create a simple observable using the makeAutoObservable helper from mobx then we export it.

To be able to use our store, we will create a React context, with an object of our MobX stores with key-value pairs, the key is what we use inside our components and the value is the observable we created.

Now, let's create a new file store/index.ts we will create a React context then we will use it to create a useStore hook and export it.

store/index.ts
import { createContext, useContext } from 'react'
import helloWorldStore from './hello-world.store'

const store = {
  helloWorld: helloWorldStore,
}

export const StoreContext = createContext(store)

export const useStore = () => {
  return useContext(StoreContext)
}

export default store

Now that we have set up our store, let's go ahead and use it, to do that we will add a new input to our homepage and wire it up with our global message state.

Open the pages/index.tsx and do the needed changes.

pages/index.tsx
...
import { observer } from 'mobx-react-lite'
import { useStore } from '@/store/index'
import ShowMessage from '@/components/common/ShowMessage'

const Home: NextPage = () => {
  const { helloWorld } = useStore()

  return (
    <>
      ...
      <main className="max-w-md mx-auto">
        ...
        <div className="mt-5 space-y-3">
          <h2 className="text-xl font-bold">Say Hello</h2>
          <input
            className="w-full h-10 px-4 border border-gray-300 focus:outline-none focus:border-gray-400 focus:shadow"
            value={helloWorld.message || ''}
            onChange={(e) => helloWorld.setMessage(e.target.value)}
          />
          <ShowMessage />
        </div>
      </main>
    </>
  )
}

export default observer(Home)

To show the message we created a new component ShowMessage which uses the same store to retrieve the message.

We use the useStore hook to get access to our store, and at the end of our component we wrap it inside the observer HoC (higher-order component)

components/common/ShowMessage.tsx
import { useStore } from '@/store/index'

const ShowMessage = () => {
  const { helloWorld } = useStore()

  return (
    <div className="p-4 border border-gray-500">
      Message: {helloWorld.message}
    </div>
  )
}

export default ShowMessage

Let's open our application inside the browser and test it out, it's a simple two-way binding that uses a MobX store to share data between two components in our situation.

Source code is available at: https://github.com/codersteps/nextjs_full_stack_boilerplate.

That's it for this one, I hope it was helpful, see you in the next one 😉.

Abdessamad Ely
Abdessamad Ely
Software Engineer

A web developer from Morocco Morocco flag , and the founder of coderstep.com a website that help developers grow and improve their skills with a set of step-by-step articles that just works.

Your Questions

  • No questions have been asked yet.

Submit your question

Please use the form bellow to submit your question, and it will be include in the list asap.