In today’s modern web development landscape, building full-stack applications has never been more efficient and streamlined. With the release of Next.js 14, developers now have access to an even more powerful framework for building React-based applications that seamlessly handle both frontend and backend logic. Paired with Prisma, a type-safe ORM, and PostgreSQL, a robust relational database, this stack offers a clean and scalable approach to building full-featured applications.
In this tutorial, you’ll learn how to build a simple full-stack Task Manager App using Next.js 14 App Router, Prisma ORM, and PostgreSQL. We'll cover everything from project setup and database modeling to creating API routes and building a responsive frontend UI using Tailwind CSS. Whether you’re new to full-stack development or looking to explore Next.js 14’s latest features, this guide will provide you with a practical hands-on experience.
1. Initialize the Project
npx create-next-app@latest nextjs14-prisma-postgres-app
cd nextjs14-prisma-postgres-app
Select the following when prompted:
-
✅ TypeScript
-
✅ App Router
-
✅ Tailwind CSS
-
✅ ESLint
-
✅ src directory
📝 This sets up a modern Next.js 14 project using the App Router and Tailwind.
2. Set Up PostgreSQL and Prisma
Install Prisma CLI and PostgreSQL driver:
npm install prisma --save-dev
npm install @prisma/client
Initialize Prisma:
npx prisma init
You’ll now have a prisma/schema.prisma
file and a .env
file.
3. Configure the Database
In .env
, set your PostgreSQL connection:
DATABASE_URL="postgresql://username:password@localhost:5432/taskdb?schema=public"
Update schema.prisma
:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Task {
id Int @id @default(autoincrement())
title String
completed Boolean @default(false)
createdAt DateTime @default(now())
}
Then run:
npx prisma migrate dev --name init
📝 This creates the PostgreSQL tables and generates the Prisma Client.
4. Seed Sample Data (Optional)
Create a prisma/seed.ts
file:
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
await prisma.task.createMany({
data: [
{ title: 'Learn Next.js 14' },
{ title: 'Explore Prisma ORM' },
{ title: 'Build Full-Stack App' },
],
});
}
main().finally(() => prisma.$disconnect());
Add to package.json
:
"prisma": {
"seed": "ts-node prisma/seed.ts"
}
Then run:
npx ts-node prisma/seed.ts
5. Create API Routes with Next.js App Router
Create a route file:
src/app/api/tasks/route.ts
import { NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function GET() {
const tasks = await prisma.task.findMany();
return NextResponse.json(tasks);
}
export async function POST(req: Request) {
const data = await req.json();
const newTask = await prisma.task.create({
data: { title: data.title },
});
return NextResponse.json(newTask);
}
📝 You now have a working REST API inside Next.js using Prisma!
6. Build the Frontend UI
Update src/app/page.tsx
:
'use client';
import { useState, useEffect } from 'react';
type Task = {
id: number;
title: string;
completed: boolean;
};
export default function Home() {
const [tasks, setTasks] = useState<Task[]>([]);
const [title, setTitle] = useState('');
useEffect(() => {
fetch('/api/tasks')
.then(res => res.json())
.then(data => setTasks(data));
}, []);
const addTask = async () => {
const res = await fetch('/api/tasks', {
method: 'POST',
body: JSON.stringify({ title }),
});
const newTask = await res.json();
setTasks([...tasks, newTask]);
setTitle('');
};
return (
<main className="p-6 max-w-xl mx-auto">
<h1 className="text-2xl font-bold mb-4">Task Manager</h1>
<div className="flex mb-4">
<input
type="text"
value={title}
onChange={e => setTitle(e.target.value)}
className="border p-2 flex-1"
placeholder="New task"
/>
<button onClick={addTask} className="bg-blue-500 text-white px-4 ml-2">
Add
</button>
</div>
<ul>
{tasks.map(task => (
<li key={task.id} className="border-b py-2">
{task.title}
</li>
))}
</ul>
</main>
);
}
📝 This is a simple UI with task listing and form input using
useClient
component.
✅ What’s Working So Far
-
✅ API Routes with Next.js 14 + App Router
-
✅ Prisma ORM connected to PostgreSQL
-
✅ Simple Tailwind UI with task creation and listing
7. Add Update and Delete API Routes
Create these in /app/api/tasks/[id]/route.ts
:
PATCH
– Update Task
// app/api/tasks/[id]/route.ts
import { PrismaClient } from '@prisma/client';
import { NextRequest, NextResponse } from 'next/server';
const prisma = new PrismaClient();
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
const id = parseInt(params.id);
const data = await req.json();
try {
const updated = await prisma.task.update({
where: { id },
data,
});
return NextResponse.json(updated);
} catch (error) {
return NextResponse.json({ error: 'Task not found or update failed.' }, { status: 400 });
}
}
DELETE
– Delete Task
// app/api/tasks/[id]/route.ts
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
const id = parseInt(params.id);
try {
await prisma.task.delete({
where: { id },
});
return NextResponse.json({ message: 'Task deleted' });
} catch (error) {
return NextResponse.json({ error: 'Task not found or deletion failed.' }, { status: 400 });
}
}
✅ These routes share the same file (
[id]/route.ts
) and exportPATCH
andDELETE
handlers.
8. Frontend: Add Update/Delete Buttons
Assuming you have a task list rendered from getTasks()
and you're using server components or React hooks:
🖱 Update Button
import { useRouter } from "next/navigation";
...
export default function Home() {
...
const router = useRouter();
return (
...
<li key={task.id} className="border-b py-2">
{task.title}
<button
onClick={async () => {
await fetch(`/api/tasks/${task.id}`, {
method: 'PATCH',
body: JSON.stringify({ completed: !task.completed }),
headers: { 'Content-Type': 'application/json' },
});
router.refresh(); // or refetch your client data
}}
>
{task.completed ? 'Mark Incomplete' : 'Mark Complete'}
</button>
</li>
...
);
}
🗑 Delete Button
<button
onClick={async () => {
await fetch(`/api/tasks/${task.id}`, {
method: 'DELETE',
});
router.refresh();
}}
>
Delete
</button>
You can add these inside your task loop (e.g., .map(task => ...)
).
Optional: Server Action Helpers (Next.js 14)
If you prefer using Server Actions instead of direct fetches, I can help you convert those to app/actions/taskActions.ts
next.
9. Clean Implementation to Support Update and Delete for Tasks
File Structure
/app
/api
/tasks
[id]
route.ts ← ✅ PATCH & DELETE routes
/components
TaskList.tsx ← ✅ Renders tasks + update/delete buttons
API Route: app/api/tasks/[id]/route.ts
// app/api/tasks/[id]/route.ts
import { PrismaClient } from '@prisma/client';
import { NextRequest, NextResponse } from 'next/server';
const prisma = new PrismaClient();
export async function PATCH(
req: NextRequest,
{ params }: { params: { id: string } }
) {
const id = parseInt(params.id);
const data = await req.json();
try {
const updated = await prisma.task.update({
where: { id },
data,
});
return NextResponse.json(updated);
} catch (error) {
return NextResponse.json({ error: 'Update failed.' }, { status: 400 });
}
}
export async function DELETE(
req: NextRequest,
{ params }: { params: { id: string } }
) {
const id = parseInt(params.id);
try {
await prisma.task.delete({
where: { id },
});
return NextResponse.json({ message: 'Task deleted.' });
} catch (error) {
return NextResponse.json({ error: 'Delete failed.' }, { status: 400 });
}
}
UI Component: components/TaskList.tsx
'use client';
import { useRouter } from 'next/navigation';
type Task = {
id: number;
title: string;
completed: boolean;
};
type Props = {
tasks: Task[];
};
export default function TaskList({ tasks }: Props) {
const router = useRouter();
const toggleCompleted = async (id: number, completed: boolean) => {
await fetch(`/api/tasks/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed: !completed }),
});
router.refresh();
};
const deleteTask = async (id: number) => {
await fetch(`/api/tasks/${id}`, {
method: 'DELETE',
});
router.refresh();
};
return (
<ul className="space-y-2">
{tasks.map((task) => (
<li
key={task.id}
className="flex items-center justify-between bg-gray-100 p-2 rounded"
>
<span
className={`flex-1 ${
task.completed ? 'line-through text-gray-500' : ''
}`}
>
{task.title}
</span>
<button
className="text-blue-600 hover:underline mr-3"
onClick={() => toggleCompleted(task.id, task.completed)}
>
{task.completed ? 'Undo' : 'Done'}
</button>
<button
className="text-red-600 hover:underline"
onClick={() => deleteTask(task.id)}
>
Delete
</button>
</li>
))}
</ul>
);
}
Use It in a Page
In app/page.tsx
or similar:
import { PrismaClient } from "@prisma/client";
import TaskList from "./components/TaskList";
const prisma = new PrismaClient();
export default async function HomePage() {
const tasks = await prisma.task.findMany({ orderBy: { createdAt: "desc" } });
return (
<main className="max-w-xl mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">Task List</h1>
<TaskList tasks={tasks} />
</main>
);
}
10. Add a Task Creation Form using a Client Component and fetch()
Make Sure You Have the POST API Route
In /app/api/tasks/route.ts
, you should already have this (or we can add it):
// app/api/tasks/route.ts
import { PrismaClient } from '@prisma/client';
import { NextRequest, NextResponse } from 'next/server';
const prisma = new PrismaClient();
export async function POST(req: NextRequest) {
const { title } = await req.json();
if (!title || title.trim() === '') {
return NextResponse.json({ error: 'Title is required' }, { status: 400 });
}
const task = await prisma.task.create({
data: {
title,
},
});
return NextResponse.json(task);
}
If this doesn't exist yet, go ahead and create that file.
Create the Form Component
Add this new file: components/CreateTaskForm.tsx
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export default function CreateTaskForm() {
const [title, setTitle] = useState('');
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) return;
setLoading(true);
await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
setTitle('');
setLoading(false);
router.refresh(); // refresh server-rendered task list
};
return (
<form onSubmit={handleSubmit} className="flex gap-2 mb-4">
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="New task"
className="flex-1 border border-gray-300 rounded px-3 py-2"
/>
<button
type="submit"
disabled={loading}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
{loading ? 'Adding...' : 'Add'}
</button>
</form>
);
}
Use It in Your page.tsx
Update your app/page.tsx
:
import { PrismaClient } from '@prisma/client';
import CreateTaskForm from '@/components/CreateTaskForm';
import TaskList from '@/components/TaskList';
const prisma = new PrismaClient();
export default async function HomePage() {
const tasks = await prisma.task.findMany({
orderBy: { createdAt: 'desc' },
});
return (
<main className="max-w-xl mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">Task List</h1>
<CreateTaskForm />
<TaskList tasks={tasks} />
</main>
);
}
11. Run the Development Server
Now you're ready to run the full app:
npm run dev
Visit the app at http://localhost:3000
Build for Production (Optional)
To test the production version locally:
npm run build
npm start
Done
You now have:
-
✅ Create via a form
-
✅ Read with server-side data
-
✅ Update/Delete with buttons
-
✅ Fully working CRUD app
Conclusion
In this tutorial, you’ve successfully built a modern full-stack application using Next.js 14, Prisma, and PostgreSQL. You learned how to:
-
Scaffold a Next.js App Router project with TypeScript and Tailwind
-
Set up Prisma ORM and connect it to a PostgreSQL database
-
Perform full CRUD operations: create, read, update, and delete tasks
-
Build clean, reusable UI components with React and Tailwind
-
Use
fetch()
androuter.refresh()
for real-time UI updates
This tech stack offers the best of both server and client paradigms, providing a scalable foundation for production-grade apps. From here, you can enhance the app by adding:
-
Authentication with NextAuth or Clerk
-
Server Actions or React Server Components for cleaner backend logic
-
Validation with Zod
-
Deployment to Vercel with environment variables
By mastering this full-stack workflow, you're well-prepared to build and ship fast, modern web applications. Happy coding!
You can get the full source code on our GitHub.
That's just the basics. If you need more deep learning about the Next.js frameworks, you can take the following cheap course:
- Next JS & Typescript with Shopify Integration - Full Guide
- Build 2+ SaaS Full Stack Projects with Next. js
- Next. js 14 e-Learning and Online Courses Marketplace App
- Next. js with React - Developer's App (Practical Way)
- Next. js 14 Lottery & Lucky Draw App
Thanks!