Abdessamad Ely Logo

Nextjs Full Stack Boilerplate

Source code for the Nextjs Full Stack Boilerplate project.

Abdessamad Ely
Abdessamad Ely
Software Engineer

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

delete.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 | null
  error: string | null
}

const deleteHandler: NextApiHandler<Data> = async (req, res) => {
  const id = typeof req.query.id === 'string' ? parseInt(req.query.id, 10) : 0

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

export default deleteHandler

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

show.handler.ts

import type { NextApiHandler } from 'next'
import { User } from '@prisma/client'
import { ReasonPhrases, StatusCodes } from 'http-status-codes'
import db from '@/lib/database'

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

const showHandler: NextApiHandler<Data> = async (req, res) => {
  const id = typeof req.query.id === 'string' ? parseInt(req.query.id, 10) : 0

  const user = await db.user.findFirst({ where: { id } })

  if (!user) {
    res
      .status(StatusCodes.NOT_FOUND)
      .json({ data: null, error: ReasonPhrases.NOT_FOUND })
  }

  res.status(StatusCodes.OK).json({ data: user, error: null })
}

export default showHandler

update.handler.ts

import type { NextApiHandler } from 'next'
import { Prisma, User } from '@prisma/client'
import { ReasonPhrases, StatusCodes } from 'http-status-codes'
import db from '@/lib/database'

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

const updateHandler: NextApiHandler<Data> = async (req, res) => {
  const id = typeof req.query.id === 'string' ? parseInt(req.query.id, 10) : 0

  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 user = await db.user.update({ data, where: { id } })
  res.status(StatusCodes.OK).json({ data: user, error: null })
}

export default updateHandler

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

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

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

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

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

[id].ts

import makeHandler from '@/core/make-handler'
import showHandler from '@/app/users/show.handler'
import updateHandler from '@/app/users/update.handler'
import deleteHandler from '@/app/users/delete.handler'

export default makeHandler([
  {
    method: 'GET',
    handler: showHandler,
  },
  {
    method: 'PUT',
    handler: updateHandler,
  },
  {
    method: 'DELETE',
    handler: deleteHandler,
  },
])

index.ts

import makeHandler from '@/core/make-handler'
import indexHandler from '@/app/users/index.handler'
import createHandler from '@/app/users/create.handler'
import helloWorld from '@/middlewares/hello-world.middleware'

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

hello.ts

// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from 'next'

type Data = {
  name: string
}

export default function handler(
  req: NextApiRequest,
  res: NextApiResponse<Data>,
) {
  res.status(200).json({ name: 'John Doe' })
}

_app.tsx

import '../styles/globals.scss'
import type { AppProps } from 'next/app'
import store, { StoreContext } from '@/store/index'

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <StoreContext.Provider value={store}>
      <Component {...pageProps} />
    </StoreContext.Provider>
  )
}

export default MyApp

_document.tsx

import Document, {
  Html,
  Head,
  Main,
  NextScript,
  DocumentContext,
} from "next/document";
import Fonts from "../components/common/Google/Fonts";

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" />
          <Fonts />
        </Head>
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    );
  }
}

export default MyDocument;

index.tsx

import Head from 'next/head'
import type { NextPage } from 'next'
import { observer } from 'mobx-react-lite'
import { useStore } from '@/store/index'
import ShowMessage from '@/components/common/ShowMessage'

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

  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 className="max-w-md mx-auto">
        <h1 className="text-3xl font-bold font-open">
          Next.js Full-Stack Boilerplate
        </h1>
        <p>
          A Next.js full-stack boilerplate with TypeScript, Tailwind CSS, and
          Prisma.js for your future full-stack apps.
        </p>

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

schema.prisma

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

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

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

hello-world.store.ts

import { makeAutoObservable } from 'mobx'

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

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

export default helloWorldStore

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

globals.scss

@tailwind base;
@tailwind components;
@tailwind utilities;

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

next.d.ts

import type { NextApiHandler } from 'next'

export declare type NextApiMiddleware = (
  handler: NextApiHandler,
) => NextApiHandler

.eslintrc.json

{
  "extends": "next/core-web-vitals"
}

.prettierrc

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

next-env.d.ts

/// <reference types="next" />
/// <reference types="next/image-types/global" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

next.config.js

/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
}

module.exports = nextConfig

package.json

{
  "name": "nextjs_full_stack_boilerplate",
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "db:push": "prisma db push",
    "db:seed": "prisma db seed",
    "db:studio": "prisma studio",
    "prisma:generate": "prisma generate"
  },
  "dependencies": {
    "@prisma/client": "^4.0.0",
    "bcrypt": "^5.0.1",
    "http-status-codes": "^2.2.0",
    "mobx": "^6.6.1",
    "mobx-react-lite": "^3.4.0",
    "next": "12.2.0",
    "react": "18.2.0",
    "react-dom": "18.2.0"
  },
  "devDependencies": {
    "@types/bcrypt": "^5.0.0",
    "@types/node": "18.0.1",
    "@types/react": "18.0.14",
    "@types/react-dom": "18.0.5",
    "autoprefixer": "^10.4.7",
    "esbuild-register": "^3.3.3",
    "eslint": "8.19.0",
    "eslint-config-next": "12.2.0",
    "postcss": "^8.4.14",
    "prisma": "^4.0.0",
    "sass": "^1.53.0",
    "tailwindcss": "^3.1.6",
    "typescript": "4.7.4"
  }
}

postcss.config.js

module.exports = {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
}

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: [],
}

tsconfig.json

{
  "compilerOptions": {
    "baseUrl": ".",
    "target": "es5",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "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"],
  "exclude": ["node_modules"]
}