Build a Full-Stack App with Next.js and Prisma
A comprehensive guide to building modern full-stack applications using Next.js, Prisma, and PostgreSQL from scratch.
Introduction
Building full-stack applications used to mean juggling multiple technologies, frameworks, and languages. Today, with Next.js and Prisma, you can build powerful, type-safe full-stack applications using just JavaScript/TypeScript. In this guide, we'll walk through creating a complete application from database to UI.
Why This Stack?
This combination offers several advantages:
Next.js provides both frontend and backend in one framework, with built-in API routes and server components.
Prisma is a modern ORM that gives you type-safe database access with an excellent developer experience.
PostgreSQL is a robust, reliable database perfect for production applications.
Together, they create a development experience that's both productive and enjoyable.
Project Setup
Let's build a simple blog application with posts, comments, and user authentication.
Create a new Next.js project:
npx create-next-app@latest my-blog --typescript --tailwind --app
cd my-blogInstall Prisma:
npm install prisma @prisma/client
npx prisma initThis creates a prisma folder with a schema.prisma file and a .env file for your database connection.
Setting Up the Database
Configure your database connection in .env:
DATABASE_URL="postgresql://username:password@localhost:5432/myblog"Define your schema in prisma/schema.prisma:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String?
posts Post[]
comments Comment[]
createdAt DateTime @default(now())
}
model Post {
id String @id @default(cuid())
title String
content String
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId String
comments Comment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Comment {
id String @id @default(cuid())
content String
post Post @relation(fields: [postId], references: [id])
postId String
author User @relation(fields: [authorId], references: [id])
authorId String
createdAt DateTime @default(now())
}Run migrations:
npx prisma migrate dev --name initThis creates your database tables and generates the Prisma Client with full TypeScript support.
Setting Up Prisma Client
Create a lib/prisma.ts file for a single Prisma Client instance:
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}This prevents multiple Prisma Client instances in development, which can exhaust database connections.
Creating API Routes
Next.js API routes handle backend logic. Let's create endpoints for posts.
Get all posts (app/api/posts/route.ts):
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET() {
try {
const posts = await prisma.post.findMany({
where: { published: true },
include: {
author: {
select: { name: true, email: true }
},
comments: true
},
orderBy: { createdAt: 'desc' }
});
return NextResponse.json(posts);
} catch (error) {
return NextResponse.json(
{ error: 'Failed to fetch posts' },
{ status: 500 }
);
}
}Create a post:
export async function POST(request: Request) {
try {
const body = await request.json();
const { title, content, authorId } = body;
const post = await prisma.post.create({
data: {
title,
content,
authorId,
},
});
return NextResponse.json(post, { status: 201 });
} catch (error) {
return NextResponse.json(
{ error: 'Failed to create post' },
{ status: 500 }
);
}
}Get a single post (app/api/posts/[id]/route.ts):
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
try {
const post = await prisma.post.findUnique({
where: { id: params.id },
include: {
author: true,
comments: {
include: {
author: true
}
}
}
});
if (!post) {
return NextResponse.json(
{ error: 'Post not found' },
{ status: 404 }
);
}
return NextResponse.json(post);
} catch (error) {
return NextResponse.json(
{ error: 'Failed to fetch post' },
{ status: 500 }
);
}
}Using Server Components
With Next.js 13+, we can fetch data directly in Server Components:
// app/posts/page.tsx
import { prisma } from '@/lib/prisma';
import Link from 'next/link';
async function getPosts() {
const posts = await prisma.post.findMany({
where: { published: true },
include: {
author: {
select: { name: true }
}
},
orderBy: { createdAt: 'desc' }
});
return posts;
}
export default async function PostsPage() {
const posts = await getPosts();
return (
<div className="max-w-4xl mx-auto p-8">
<h1 className="text-4xl font-bold mb-8">Blog Posts</h1>
<div className="space-y-6">
{posts.map((post) => (
<article key={post.id} className="border rounded-lg p-6">
<Link href={`/posts/${post.id}`}>
<h2 className="text-2xl font-semibold mb-2 hover:text-blue-600">
{post.title}
</h2>
</Link>
<p className="text-gray-600 mb-2">
By {post.author.name}
</p>
<p className="text-gray-700">
{post.content.substring(0, 150)}...
</p>
</article>
))}
</div>
</div>
);
}No API route needed! Server Components can query the database directly.
Individual Post Page
// app/posts/[id]/page.tsx
import { prisma } from '@/lib/prisma';
import { notFound } from 'next/navigation';
async function getPost(id: string) {
const post = await prisma.post.findUnique({
where: { id },
include: {
author: true,
comments: {
include: {
author: true
},
orderBy: { createdAt: 'desc' }
}
}
});
return post;
}
export default async function PostPage({
params
}: {
params: { id: string }
}) {
const post = await getPost(params.id);
if (!post) {
notFound();
}
return (
<article className="max-w-4xl mx-auto p-8">
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
<p className="text-gray-600 mb-8">
By {post.author.name} • {new Date(post.createdAt).toLocaleDateString()}
</p>
<div className="prose prose-lg mb-12">
{post.content}
</div>
<section className="border-t pt-8">
<h2 className="text-2xl font-bold mb-4">
Comments ({post.comments.length})
</h2>
<div className="space-y-4">
{post.comments.map((comment) => (
<div key={comment.id} className="border-l-4 border-blue-500 pl-4">
<p className="font-semibold">{comment.author.name}</p>
<p className="text-gray-700">{comment.content}</p>
<p className="text-sm text-gray-500 mt-1">
{new Date(comment.createdAt).toLocaleDateString()}
</p>
</div>
))}
</div>
</section>
</article>
);
}Adding a Comment Form
For interactivity, we need a Client Component:
// components/CommentForm.tsx
'use client';
import { useState } from 'react';
export function CommentForm({ postId }: { postId: string }) {
const [content, setContent] = useState('');
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
try {
const response = await fetch(`/api/posts/${postId}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content })
});
if (response.ok) {
setContent('');
// Refresh the page to show new comment
window.location.reload();
}
} catch (error) {
console.error('Failed to post comment:', error);
} finally {
setLoading(false);
}
}
return (
<form onSubmit={handleSubmit} className="mt-8">
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Add a comment..."
className="w-full p-3 border rounded-lg"
rows={4}
required
/>
<button
type="submit"
disabled={loading}
className="mt-2 px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
{loading ? 'Posting...' : 'Post Comment'}
</button>
</form>
);
}Advanced Prisma Features
Transactions:
const [post, user] = await prisma.$transaction([
prisma.post.create({ data: postData }),
prisma.user.update({ where: { id }, data: userData })
]);Pagination:
const posts = await prisma.post.findMany({
skip: (page - 1) * 10,
take: 10,
orderBy: { createdAt: 'desc' }
});Full-text search:
const posts = await prisma.post.findMany({
where: {
OR: [
{ title: { contains: searchTerm } },
{ content: { contains: searchTerm } }
]
}
});Production Considerations
Connection Pooling:
Configure Prisma for production in schema.prisma:
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
connectionLimit = 10
}Error Handling: Always wrap database operations in try-catch blocks and return appropriate HTTP status codes.
Validation: Use libraries like Zod to validate request data before database operations.
Security: Never trust client input. Always validate and sanitize data before database queries.
Conclusion
Building full-stack applications with Next.js and Prisma offers a modern, type-safe development experience. You get:
- Type safety from database to UI
- Excellent developer experience
- Built-in optimizations
- Single codebase for frontend and backend
- Easy deployment
This stack is perfect for startups, MVPs, and production applications alike. The combination of Next.js's flexibility and Prisma's type-safe database access creates a powerful foundation for any web application.
Start with a simple project, get comfortable with the patterns, and you'll soon be building complex applications with confidence. The future of web development is full-stack, and this stack puts you right at the forefront.