Lately, I’ve been thinking a lot about how to build full-stack applications faster and with more confidence. TypeScript helps, but how do we connect it all the way from the database to the UI? That’s where Next.js and Prisma come in—they work together so well it feels like they were made for each other.
When you use Next.js for your frontend and API routes, and Prisma as your database layer, something special happens. You get a single, type-safe codebase from top to bottom. Have you ever spent hours debugging because a field name changed in the database but not in your API? That’s the kind of problem this setup helps avoid.
Let’s look at how it works. First, you define your database schema using Prisma. Here’s a simple example for a blog post:
model Post {
id Int @id @default(autoincrement())
title String
content String?
published Boolean @default(false)
authorId Int
author User @relation(fields: [authorId], references: [id])
}
model User {
id Int @id @default(autoincrement())
name String
email String @unique
posts Post[]
}
After running npx prisma generate
, Prisma creates a fully typed client. Now, inside a Next.js API route, querying the database becomes straightforward and safe:
import { NextApiRequest, NextApiResponse } from 'next';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'GET') {
const posts = await prisma.post.findMany({
where: { published: true },
include: { author: true },
});
res.status(200).json(posts);
}
}
Notice how we’re using include
to get the author data along with each post. The types for posts
are automatically inferred, so if you try to access a field that doesn’t exist, TypeScript will catch it right away.
What about the frontend? In a Next.js page, you can fetch this data on the server or client. Here’s how you might do it with getServerSideProps
:
import { GetServerSideProps } from 'next';
import { PrismaClient, Post } from '@prisma/client';
const prisma = new PrismaClient();
export const getServerSideProps: GetServerSideProps = async () => {
const posts = await prisma.post.findMany({
where: { published: true },
include: { author: true },
});
return { props: { posts } };
};
type Props = {
posts: (Post & { author: { name: string } })[];
};
export default function Home({ posts }: Props) {
return (
<div>
{posts.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>By {post.author.name}</p>
</article>
))}
</div>
);
}
Everything is connected. Change your database schema, and your types update automatically. Your API routes and frontend components stay in sync. It’s a smooth experience that lets you focus on building features instead of fixing mismatches.
But what if you need to handle mutations? Creating new records is just as clean. Here’s an example of an API route for adding a new user:
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'POST') {
const { name, email } = req.body;
const user = await prisma.user.create({
data: { name, email },
});
res.status(201).json(user);
}
}
With Prisma, you also get built-in protection against common issues like SQL injection, and you can easily optimize queries. Plus, Next.js API routes make it simple to structure your backend logic without a separate server.
Ever wondered how much easier development could be if your database and frontend just understood each other? This combination makes it possible. You write less code, make fewer mistakes, and move faster.
I’ve been using this setup for a while now, and it’s changed how I approach full-stack projects. The feedback loop is tighter, and the confidence in my code is higher. Whether you’re building a small side project or a larger application, giving Next.js and Prisma a try might just change your workflow too.
If you found this helpful or have your own experiences to share, I’d love to hear from you—feel free to leave a comment, and don’t forget to share this with others who might benefit!