Build a Full-Stack Blog Editor with Next.js, Prisma 7, and Supabase

In this tutorial, we'll set up a simple full-stack blog editor using Next.js App Router, Prisma 7, and Supabase PostgreSQL.

You'll learn how to connect Prisma 7 to Supabase, define a blog post model, create a Prisma Client singleton, and save posts using a Next.js Server Action.

🛠️ Tech Stack

  • Framework: Next.js (App Router)
  • ORM: Prisma 7
  • Database: Supabase (PostgreSQL)
  • PostgreSQL driver: pg
  • Prisma adapter: @prisma/adapter-pg
  • Styling: Tailwind CSS

1. Create a Next.js project

If you don't already have a project, create one:

npx create-next-app@latest my-blog cd my-blog 

Choose the options you prefer. This example uses the src directory.

2. Install dependencies

Prisma 7 uses a database driver adapter at runtime. Install the Prisma Client, PostgreSQL driver, and adapter:

npm install @prisma/client@7 @prisma/adapter-pg pg dotenv npm install -D prisma@7 

3. Configure Supabase environment variables

Create a .env file in your project root.

In your Supabase dashboard, open Project Settings → Database and copy the connection details.

# Runtime connection: Supabase connection pooler DATABASE_URL="postgresql://postgres.[PROJECT-REF]:[PASSWORD]@[POOLER-HOST]:6543/postgres?pgbouncer=true" # Migration connection: direct database connection DIRECT_URL="postgresql://postgres:[PASSWORD]@db.[PROJECT-REF].supabase.co:5432/postgres" 

Replace the placeholders with your actual Supabase values. Depending on your network and Supabase configuration, you may need to use the session pooler for migrations instead of the direct database host.

Important: Never commit .env or expose database connection strings in client-side code. Keep credentials private.

4. Configure Prisma 7

Create prisma.config.ts in the project root:

import "dotenv/config"; import { defineConfig, env } from "prisma/config"; export default defineConfig({ schema: "prisma/schema.prisma", migrations: { path: "prisma/migrations", }, datasource: { // Used by Prisma Migrate url: env("DIRECT_URL"), }, }); 

In Prisma 7, the database connection URL is configured in prisma.config.ts, rather than inside the datasource block in schema.prisma.

5. Define your Prisma schema

Create or update prisma/schema.prisma:

generator client { provider = "prisma-client" output = "../src/generated/prisma" } datasource db { provider = "postgresql" } model Post { id Int @id @default(autoincrement()) title String content String videoLink String? tags String[] published Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } 

A few things to notice:

  • id is the unique primary key for each post.
  • videoLink is optional, so it can be null.
  • tags is a PostgreSQL string array.
  • published distinguishes drafts from published posts.
  • createdAt and updatedAt track when a post was created and last updated.

6. Run the migration and generate Prisma Client

Run:

npx prisma migrate dev --name init npx prisma generate 

This creates the database table in Supabase and generates the Prisma Client in src/generated/prisma.

7. Create a Prisma Client singleton

Create src/lib/prisma.ts:

import { PrismaClient } from "@/generated/prisma/client"; import { PrismaPg } from "@prisma/adapter-pg"; const connectionString = process.env.DATABASE_URL; if (!connectionString) { throw new Error("DATABASE_URL is not set"); } const adapter = new PrismaPg({ connectionString }); const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined; }; export const prisma = globalForPrisma.prisma ?? new PrismaClient({ adapter }); if (process.env.NODE_ENV !== "production") { globalForPrisma.prisma = prisma; } 

The singleton pattern reuses the Prisma Client during Next.js development hot reloads, helping prevent unnecessary database connections.

8. Create a Server Action

Create src/app/actions.ts:

"use server"; import { prisma } from "@/lib/prisma"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; export async function createPost(formData: FormData) { const title = String(formData.get("title") ?? "").trim(); const content = String(formData.get("content") ?? "").trim(); const videoLink = String(formData.get("videoLink") ?? "").trim(); const tagsString = String(formData.get("tags") ?? ""); const actionType = String(formData.get("actionType") ?? "draft"); if (!title || !content) { throw new Error("Title and content are required."); } const tags = tagsString .split(",") .map((tag) => tag.trim()) .filter(Boolean); await prisma.post.create({ data: { title, content, videoLink: videoLink || null, tags, published: actionType === "publish", }, }); revalidatePath("/"); redirect("/"); } 

This Server Action:

  1. Reads the submitted form values.
  2. Validates the required title and content.
  3. Converts comma-separated tags into a string array.
  4. Saves the post as a draft or publishes it.
  5. Revalidates the home page and redirects.

For a production application, also add authentication, authorization, and robust input validation. A Server Action is an entry point and should not be treated as automatically authorized.

9. Build the blog editor UI

Create src/app/create/page.tsx:

import { createPost } from "@/app/actions"; export default function CreatePostPage() { return ( <main className="mx-auto max-w-2xl p-8"> <h1 className="mb-6 text-3xl font-bold">Create a blog post</h1> <form action={createPost} className="flex flex-col gap-4"> <input type="text" name="title" placeholder="Post title" required className="rounded-lg border p-3" /> <textarea name="content" placeholder="Write your post..." required rows={10} className="rounded-lg border p-3" /> <input type="url" name="videoLink" placeholder="Optional video URL" className="rounded-lg border p-3" /> <input type="text" name="tags" placeholder="Tags, separated by commas" className="rounded-lg border p-3" /> <div className="flex gap-3"> <button type="submit" name="actionType" value="draft" className="rounded-lg border px-5 py-2" > Save draft </button> <button type="submit" name="actionType" value="publish" className="rounded-lg bg-black px-5 py-2 text-white" > Publish post </button> </div> </form> </main> ); } 

The form submits directly to the Server Action. The selected button determines whether the post is saved as a draft or published.

10. Display saved posts

Create or update src/app/page.tsx:

import { prisma } from "@/lib/prisma"; export default async function HomePage() { const posts = await prisma.post.findMany({ orderBy: { createdAt: "desc", }, }); return ( <main className="mx-auto max-w-3xl p-8"> <h1 className="mb-6 text-3xl font-bold">My Blog</h1> {posts.length === 0 ? ( <p className="text-gray-500">No posts yet.</p> ) : ( <div className="space-y-4"> {posts.map((post) => ( <article key={post.id} className="rounded-lg border p-5"> <h2 className="text-xl font-semibold">{post.title}</h2> <p className="mt-2 whitespace-pre-wrap">{post.content}</p> <p className="mt-3 text-sm text-gray-500"> {post.published ? "Published" : "Draft"} </p> {post.tags.length > 0 && ( <p className="mt-2 text-sm">Tags: {post.tags.join(", ")}</p> )} </article> ))} </div> )} </main> ); } 

11. Start the development server

npm run dev 

Open http://localhost:3000 and visit /create to write your first post.

⚠️ Important: Supabase Row Level Security (RLS)

When Prisma connects directly to PostgreSQL using a database connection string, queries do not automatically go through Supabase's PostgREST API. Therefore, Supabase Row Level Security policies are not automatically applied to these Prisma queries.

Protect your Server Actions with authentication and authorization, validate user input, and keep database credentials on the server. Do not assume that enabling RLS alone protects queries made through a privileged direct database connection.