Next.js 16 + Prisma 7: A Markdown Blog Stored in PostgreSQL
Mohammed Moutawakkil
6 min read
Next.jsPrismaPostgreSQLMarkdown
This blog used to be five hand-written React pages, one folder per article. Every new post meant copying a folder, pasting JSX and fixing class names. I moved the articles into PostgreSQL as Markdown, and now one Next.js route renders all of them.
In this article I'll walk through how it works on this site: the Prisma 7 schema, the data layer, the Markdown renderer, the seed script, and a few Next.js 16 surprises I ran into on the way.
The Stack
Next.js 16 with the App Router and Server Components
Prisma 7 with the @prisma/adapter-pg driver adapter
PostgreSQL for storage
react-markdown 10 to turn Markdown into React elements
Step 1: The Schema
A post is one row. The body is plain Markdown in a String column, and the slug is unique because it is the URL.
Prisma
model BlogPost {
id Int @id @default(autoincrement())
slug String @unique
title String
excerpt String @default("")
coverImg String
content String // Markdown
tags String[]
published Boolean @default(true)
publishedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([published, publishedAt])
}
The index on published, publishedAt matches the only list query the site runs: published posts, newest first.
The client connects through the pg driver adapter. In development, Next.js reloads modules all the time, so I keep one client on globalThis. Otherwise every reload would open new connections:
TypeScript
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@/lib/generated/prisma/client";
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
function createClient() {
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
return new PrismaClient({ adapter });
}
export const prisma = globalForPrisma.prisma ?? createClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
Step 3: The Data Layer
All database reads for pages live in one file. Two details matter here.
connection() from next/server tells Next.js the page depends on the request. The page renders per request and shows current data. The build doesn't need a database either, which keeps CI simple.
cache() from React deduplicates the call. The post page and its generateMetadata both need the post, and they share one query per request.
Converting publishedAt to an ISO string keeps the shape identical to what the JSON API returns, so the pages and the API share one Article type.
Step 4: One Route for Every Post
app/blog/[slug]/page.tsx loads the post, calls notFound() when it doesn't exist, and passes the Markdown to react-markdown:
TSX
export default async function BlogPost({ params }: Props) {
const post = await getPublishedPost((await params).slug);
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
<Markdown components={components}>{post.content}</Markdown>
</article>
);
}
The components map is where the design lives. Each Markdown element maps to a styled React element, so articles look the same as the old hand-written pages without any HTML in the database:
Older tutorials use an inline prop to tell inline code from code blocks. react-markdown 10 doesn't pass it anymore. Here is what works instead:
A fenced block with a language has a language-* class.
A fenced block without a language still contains a newline.
A fenced block is wrapped in <pre>. Map pre to a fragment and let the code component draw the whole block, otherwise you get two nested boxes.
TSX
pre: ({ children }) => <>{children}</>,
code: ({ className, children }) => {
const language = /language-(\w+)/.exec(className ?? "")?.[1];
const text = String(children);
if (!language && !text.includes("\n")) return <code className={inlineCode}>{children}</code>;
return (
<pre className="bg-muted/50 p-6 rounded-xl overflow-x-auto">
<code>{text.replace(/\n$/, "")}</code>
</pre>
);
},
Step 5: A Seed You Can Run Twice
The articles start as Markdown files in the repo, next to a small JSON index with title, tags, cover and date. The seed script reads them and upserts by slug:
Because it upserts, running the seed again updates existing posts and adds no duplicates. The excerpt comes for free: it's the first paragraph of the article, which is also the meta description.
One trap with dates: new Date("April 9, 2025") is parsed in the server's local timezone. On a server east or west of UTC that can shift the day. I build the date with Date.UTC from the parsed year, month and day, so the stored day is always the one in the file.
Three Next.js 16 Surprises
1. Open Graph objects don't merge
When a page sets openGraph in its metadata, Next.js replaces the parent layout's whole openGraph object. It does not merge them. A post that sets only a title loses the site's preview image. I solved this with one pageMetadata() helper that always builds the full object, including the image, which for posts is the cover.
2. Layout titles can block the template
If a nested layout sets a plain string title, the root layout's title.template no longer reaches the pages below it. Set a template at each level, or use title: { absolute: "..." } where you need full control.
3. loading.tsx changes your 404 status
With a root app/loading.tsx, dynamic pages start streaming a 200 response before the page code runs. When notFound() is called later, the 404 page renders with noindex, but the status code is already 200. Search engines still drop the page because of noindex. If you need a real 404 status, keep that loading boundary away from your dynamic routes.
What I Gained
One file renders every post. A new article is a Markdown file plus one entry in an index.
No HTML in the database. The design stays in code, so a restyle updates every article at once.
Simple builds. Pages read the database at request time, so CI builds without one.
The article you are reading was published this way.
Conclusion
Storing Markdown in PostgreSQL is a small change, and it replaced a lot of copy-paste. Prisma 7 handles the data, react-markdown handles the rendering, and a components map keeps the design in one place.
If you are building something similar or have questions about the setup, feel free to get in touch.