Building This Blog with Next.js 16 and Supabase
The blog you are reading runs on the same stack as the rest of this portfolio: Next.js 16 App Router, React 19, and a Supabase Postgres backend. This post walks through how the pieces fit together.
Content model
Posts live in two tables: a locale-neutral blog_posts row plus one blog_post_translations row per language. Markdown is stored raw and rendered at request time, so both English and Vietnamese versions share the same slug, category and cover image.
Why bilingual rows instead of JSON columns
Typed columns keep queries honest. A missing Vietnamese translation simply hides the post from the Vietnamese listing instead of rendering half-localized content.
Reading path
A single repository module owns every public query. It maps database rows into view models, computes reading time from the markdown body, and never leaks raw rows into components.
const listing = await getPublishedPosts(locale, page);
Listings are wrapped in a tagged cache so admin edits revalidate everything through one tag.
Takeaways
- Model translations as rows, not documents
- Render markdown on the server, ship HTML to the client
- One cached accessor per read path keeps invalidation simple


