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:
-
idis the unique primary key for each post. -
videoLinkis optional, so it can benull. -
tagsis a PostgreSQL string array. -
publisheddistinguishes drafts from published posts. -
createdAtandupdatedAttrack 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:
- Reads the submitted form values.
- Validates the required title and content.
- Converts comma-separated tags into a string array.
- Saves the post as a draft or publishes it.
- 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.
