Create a fullstack Next.js 15 app with Drizzle ORM, Postgresql, Docker
Dec 24, 2024
•8 minute read•130 viewsIn 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 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 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.