Create a fullstack Next.js 15 app with Drizzle ORM, Postgresql, Docker

Photo by Goran Ivos on Unsplash

Create a fullstack Next.js 15 app with Drizzle ORM, Postgresql, Docker

In modern web development, integrating a robust backend with a responsive frontend is crucial for building powerful and scalable applications. With Next.js, creating dynamic full stack applications have become easier with the usage of server components. No dynamic application can work without a scalable and powerful database. While NoSQL databases like MongoDB, are widely used, many industries still stick to SQL databases.

In this guide, we'll explore how to set up a full stack Next.js application using Drizzle ORM for database interactions, PostgreSQL for relational data storage, and Docker for environment consistency.

What we’ll build

In this tutorial, we will create a simple task manager application that allows users to:

  • Create, read, update, and delete tasks.

  • Persist data using PostgreSQL.

  • Use Drizzle ORM to simplify database queries.

  • Leverage Docker for seamless development and deployment.

Prerequisites

To get started with this project, we will need the following to be installed:

  • Node.js(v18 or later)

  • Docker

  • An IDE (e.g. VS Code)

and some basic knowledge of Typescript.

Step 1: Setup the Next.js project

To setup the Next.js project, we need to run the following commands

npx create-next-app@latest next-drizzle-docker --use-pnpm

We will be using pnpm as our package manager. Ensure you select Typescript and App Router during installation.

Navigate into the project directory and install dependencies

cd next-drizzle-docker
pnpm install

Install additional dependencies

pnpm add postgres drizzle-orm drizzle-kit

Step 2 : Configure PostgreSQL with Docker Compose

Create a docker-compose.yaml file in the root directory to set up a PostgreSQL Database

version: '3.8'
services:
  postgres:
    image: postgres:15
    container_name: next_postgres
    environment:
      POSTGRES_USER: admin
      POSTGRES_PASSWORD: admin
      POSTGRES_DB: tasks_db
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
volumes:
  postgres_data:

Spin up the PostgreSQL service with the following command:

docker-compose up -d

Verify that the database is running:

docker ps

Step 3: Set Up Drizzle ORM

Initialize Drizzle ORM

Create a drizzle.config.ts file in the project root:

import { defineConfig } from 'drizzle-kit';
export default defineConfig({
    out: './drizzle',
    schema: './db/schema.ts',
    dialect: 'postgresql',
    dbCredentials: {
        url: process.env.DATABASE_URL || "postgres://admin:admin@localhost:5432/your_db",
    },
});

In the package.json file, create a new script to perform migrations.

"scripts": {
    // other required scripts
  "migrate": "drizzle-kit generate && drizzle-kit push"
}

Create a PostgreSQL instance

In the root of the project create a config folder and a db subfolder (can vary according to your file conventions). In the db subfolder, create an index.ts file and create a postgres instance.

import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";


import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";

// Dev environment solve for "sorry, too many clients already"
declare global {
    // eslint-disable-next-line no-var -- only var works here
    var db: PostgresJsDatabase | undefined;
}

let db: PostgresJsDatabase;

if (process.env.NODE_ENV === "production") {
    db = drizzle(postgres(`${process.env.DATABASE_URL}`));
} else {
    if (!global.db) global.db = drizzle(postgres(`${process.env.DATABASE_URL}`));

    db = global.db;
}

export { db };

Create the Database Schema

In the root of the project directory, create a db/schema.ts file where the data schema will be stored.

import { pgTable, serial, text, boolean } from "drizzle-orm/pg-core";

export const tasks = pgTable("tasks_table", {
    id: serial("id").primaryKey(),
    title: text().notNull(),
    description: text().notNull(),
    completed: boolean().default(false),
});

Step 4: Create the backend logic

We will write all the database operations in side a repositories folder.

import { db } from "@/config/db"
import { tasks } from "@/config/db/schema"
import { determinePagination } from "@/lib/utils"
import { count, eq, sql } from "drizzle-orm"

const taskReponseBody = {
    id: tasks.id,
    title: tasks.title,
    description: tasks.description,
    completed: tasks.completed
}
// create prepared statements for optimized queries
const allTasksCount = db.select({ total: count() })
    .from(tasks).prepare("all_tasks_count")
const allTasksQuery = db.select(taskReponseBody)
    .from(tasks)
    .limit(sql.placeholder('size'))
    .offset((Number(sql.placeholder('page')) - 1) * Number(sql.placeholder('size')))
    .prepare("all_tasks")


const getAllTasks = async (page = 1, size = 10) => {
    try {
        const [totalResult, data] = await Promise.all([
            allTasksCount.execute(),
            allTasksQuery.execute({ page, size }),
        ]);
        const total = totalResult[0].total
        return { total, data, ...determinePagination(total, page, size) };
    } catch (error: unknown) {
        if (error instanceof Error)
            throw new Error(error.message);
    }
}

The determinePagination function helps communicating to the frontend regarding pagination status.

function determinePagination(total: number, page: number, page_size: number) {
    if (total <= 0 || page <= 0) {
        // No pages available if total is 0 or negative or page number is invalid
        return { hasNextPage: false, hasPrevPage: false };
    }

    const totalPages = Math.ceil(total / page_size); // Total number of pages
    const hasPrevPage = page > 1 && page <= totalPages;
    const hasNextPage = page < totalPages;

    return { hasNextPage, hasPrevPage };
}

Similarly, we can write the functions to create, update and delete tasks.

const createNewTask = async (data: typeof tasks.$inferInsert) => {
    if (!data.title) throw new Error("Title is required")
    const createdTask = await db.insert(tasks).values({ title: data.title, description: data.description, completed: data.completed }).returning();
    return createdTask;
}

const deleteTask = async (id: number) => {
    const deletedTask = await db.delete(tasks).where(eq(tasks.id, id)).returning();
    return deletedTask;
}
type UpdateTaskType
{
    id: number, 
    title?: string, 
    description?: string, 
    completed?: boolean 
}
const updateTask = async (data: ) => {
    if (!data.id) throw new Error("Task id is required")
    const updatedTask = await db.update(tasks).set(data).where(eq(tasks.id, data.id)).returning();
    return updatedTask;
}

Step 5: Create APIs to communicate with frontend

In the app directory, create a new folder named api and a sub folder tasks to create the route.

app/
├─ api/
│  ├─ tasks/
│  │  ├─ [taskId]/
│  │  │  ├─ route.ts
│  │  ├─ route.ts

In the tasks/route.ts file, we will write the code for listing all tasks and creating a new task.

import { NextRequest, NextResponse } from "next/server";
import { createNewTask, getAllTasks } from "@/repositories/tasks.repositories";
// GET tasks
export async function GET(req: NextRequest) {
    const searchParams = req.nextUrl.searchParams
    try {
        const page = Number(searchParams.get('page')) || 1;
        const size = Number(searchParams.get('size')) || 1;
        const allTasks = await getAllTasks(page, size);
        return NextResponse.json(allTasks);

    } catch (error) {
        if (error instanceof Error) {
            return NextResponse.json({ message: error.message }, { status: 500 });
        }
    }
}

// POST task
export async function POST(req: Request) {
    try {
        const { title, description, completed } = await req.json();
        const newTask = await createNewTask({ title, description, completed });
        return NextResponse.json(newTask);
    } catch (error) {
        if (error instanceof Error) {
            return NextResponse.json({ message: error.message }, { status: 500 });
        }
    }
}

When we want to update the task, or delete the task, we would be using the path parameters.
In the [taskId]/route.ts, we will write the PATCH and DELETE methods.

export async function PATCH(req: Request, { params }: { params: Promise<{ taskId: string }> }) {
    try {
        const taskId = (await params).taskId
        if (!taskId || isNaN(Number(taskId))) throw new Error("Task id is required to delete record")
        const data = await req.json();
        const updateObject: { id: number, title?: string, description?: string, completed?: boolean } = {
            id: Number(taskId)
        };
        if (data.title !== undefined)
            updateObject.title = data.title;
        if (data.description !== undefined)
            updateObject.description = data.description;
        if (data.completed !== undefined)
            updateObject.completed = data.completed;
        const updatedTask = await updateTask(updateObject)
        return NextResponse.json({ message: "Task updated", data: updatedTask });
    } catch (error) {
        if (error instanceof Error) {
            console.log(error)
            return NextResponse.json({ message: error.message }, { status: 500 });
        }
    }
}

// DELETE task
export async function DELETE(req: Request, { params }: { params: { taskId: string } }) {
    try {
        const id = params.taskId;
        if (!id || isNaN(Number(id))) 
        throw new Error("Task id is required to delete record");

        await deleteTask(Number(id));
        return NextResponse.json({ message: "Task deleted" });
    } catch (error) {
        if (error instanceof Error) {
            return NextResponse.json({ message: error.message }, { status: 500 });
        }
    }
}

Step 6 : Integrate the frontend

Below is a very rudimentary code for integrating the APIs. For a more detailed and better version, please checkout the GitHub repository here!

"use client";

import { useState, useEffect } from "react";

const TaskManager = () => {
  const [tasks, setTasks] = useState([]);
  const [newTask, setNewTask] = useState("");

  const fetchTasks = async () => {
    const res = await fetch("/api/tasks");
    const data = await res.json();
    setTasks(data);
  };

  const addTask = async () => {
    await fetch("/api/tasks", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ title: newTask }),
    });
    setNewTask("");
    fetchTasks();
  };

  const deleteTask = async (id: number) => {
    await fetch("/api/tasks", {
      method: "DELETE",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ id }),
    });
    fetchTasks();
  };

  useEffect(() => {
    fetchTasks();
  }, []);

  return (
    <div>
      <h1>Task Manager</h1>
      <input
        value={newTask}
        onChange={(e) => setNewTask(e.target.value)}
        placeholder="New task"
      />
      <button onClick={addTask}>Add Task</button>
      <ul>
        {tasks.map((task: any) => (
          <li key={task.id}>
            {task.title}{" "}
            <button onClick={() => deleteTask(task.id)}>Delete</button>
          </li>
        ))}
      </ul>
    </div>
  );
};

export default TaskManager;

Step 7: Run Your Application

Start the Next.js development server:

pnpm run dev

Visit localhost:3000 to view your application.

Step 8: Deploy with Docker

To prepare your app for deployment, create a Dockerfile in your project root:

# Use an official Node.js runtime as the base image
FROM node:20-alpine

# Set the working directory in the container
WORKDIR /app

# Copy package.json and pnpm-lock.yaml (if you have one)
COPY package*.json pnpm-lock.yaml* ./

# Install pnpm
RUN npm install -g pnpm

# Install dependencies
RUN pnpm install 

# Copy the rest of the application code
COPY . .
# Build the Next.js application
RUN pnpm build
# Expose the port the app runs on
EXPOSE 3000

# Start the application
CMD ["pnpm", "start"]

Build and run your Docker container:

docker build -t next-drizzle-app .
docker run -p 3000:3000 next-drizzle-app

Conclusion

You’ve successfully built and containerized a fullstack Next.js application using Drizzle ORM, PostgreSQL, and Docker. This setup provides a scalable foundation for more complex applications while maintaining flexibility and ease of development.

What’s next? You could:

  • Add user authentication with Auth.js.

  • Extend the database schema to support relationships.

  • Deploy the app to platforms like Cloud Run, Digital Ocean etc.