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:
## 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:
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.
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:
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:
// 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.
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.
/** @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.
import '../styles/globals.scss'
Now run npm install -D sass
to install sass, and update the styles/globals.scss
file to match the following.
@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.
{
"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.
// 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.
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".
# 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.
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.
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.
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.
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.
{
...
"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.
{
...
"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.
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.
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.
{
"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.
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.
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.
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:
[
{
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.
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.
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.
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.
export declare type NextApiMiddleware = (
handler: NextApiHandler,
) => NextApiHandler
Now, let's create a new file middlewares/hello-world.middleware.ts
for our hello_world
middleware.
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.
...
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.
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.
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.
...
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)
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 😉.