Back to All Blogs

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.

7 min readBy Qtechs
Next.jsPrismaPostgreSQLFull-StackDatabaseTypeScript

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-blog

Install Prisma:

npm install prisma @prisma/client
npx prisma init

This 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 init

This 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.

Q

Qtechs

Author

Related Posts

Why Next.js Is Perfect for Modern Web Apps

8th Feb 255 min read

Discover how Next.js combines performance, SEO, and developer experience to create lightning-fast web applications that scale.

Read More →

10 TypeScript Tips for Writing Better Code

5th Feb 255 min read

Master TypeScript with these practical tips that will help you write more maintainable, type-safe code and avoid common pitfalls.

Read More →

Getting Started with React Server Components

1st Feb 256 min read

Learn how React Server Components are revolutionizing web development by reducing bundle sizes and improving performance.

Read More →