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 well 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-dockerpnpm 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/datavolumes: 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 queriesconst 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

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 well 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-dockerpnpm 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/datavolumes: 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 queriesconst 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 tasksexport 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 taskexport 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 taskexport 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 ( Task Manager
setNewTask(e.target.value)} placeholder="New task" /> {tasks.map((task: any) => ( - {task.title}{" "}
))}
);};export default TaskManager;
Step 7: Run Your Application
Start the Next.js development server:
pnpm run dev
Visit http://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 imageFROM node:20-alpine# Set the working directory in the containerWORKDIR /app# Copy package.json and pnpm-lock.yaml (if you have one)COPY package*.json pnpm-lock.yaml* ./# Install pnpmRUN npm install -g pnpm# Install dependenciesRUN pnpm install # Copy the rest of the application codeCOPY . .# Build the Next.js applicationRUN pnpm build# Expose the port the app runs onEXPOSE 3000# Start the applicationCMD ["pnpm", "start"]
Build and run your Docker container:
docker build -t next-drizzle-app .docker run -p 3000:3000 next-drizzle-app
Conclusion
Youve 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.
Whats 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.