Building your own Markdown blog engine with Next.js, Supabase, and Shiki: the stack behind tausif1337.dev
Every developer eventually writes their own blog engine. The lazy version is markdown files in a repo. That works until you want scheduled publishing, an admin UI for non technical guests, image uploads, and analytics. Then you start reaching for a hosted CMS, and the monthly bill creeps in.
I went the other way. The blog you are reading runs on a tiny CMS built into tausif1337.dev, and the entire thing fits comfortably inside a single Next.js app.
The stack
- Next.js 16 (App Router) for the public blog and the admin panel.
- Supabase Postgres for the
poststable, Supabase Auth to gate the admin, Supabase Storage for cover image uploads. - unified / remark / rehype to render markdown, with Shiki for syntax highlighting at build time.
- Server Actions for create, update, delete, and CSV import. No REST controller, no API route boilerplate.
- Resend for the subscriber confirmation emails.
That is the whole CMS. Around 1500 lines of TypeScript across the admin and the rendering pipeline.
Why Server Actions changed how I build admin panels
The old way of building an admin panel was a REST or tRPC backend, a React form, a fetch call, a loading state, an error state, optimistic updates. Server Actions collapse that into a single function.
'use server'
export async function updatePostAction(id, prev, formData) {
const supabase = await createClient()
const user = await getAdminUser(supabase)
if (!user) return { ok: false, error: 'Not authorized.' }
// ...validate, update, revalidate
}The form posts to the action. Next.js handles the navigation. The cache for /blog and /blog/[slug] revalidates with revalidatePath. No fetch wrapper, no useMutation hook, no JSON serialization layer.
For a one person CMS, this is the right amount of plumbing.
Markdown rendering that actually looks good
The default react-markdown setup is fine until you want code blocks that look like a real syntax highlighter. The pipeline I run on this site:
unified
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeRaw)
.use(rehypeShiki, { themes: { light: 'github-light', dark: 'github-dark' } })
.use(rehypeStringify)
Shiki runs at request time and produces HTML that already has the colors baked in. No client side JavaScript for highlighting, no flash of unstyled code. SEO loves it because the source HTML is the rendered HTML.
Admin only fields you do not want in the public payload
The post row has columns the public never needs to see. In my case, social distribution drafts, internal notes, SEO overrides that are still in flux. There are two ways to keep them private. The lazy way is to omit them from the SELECT your public queries make. The right way is to put them on a separate table and let Row Level Security enforce the boundary.
I do both. Public reads go through getPublishedPosts which only selects a known column list. Anything sensitive lives on post_social_versions, a sibling table with RLS that denies anon entirely. Even if a future query accidentally requests it, the policy refuses to return the row.
What I would tell anyone building their own
Do not build a CMS unless your blog is the product. For most engineers, hosted is faster.
But if your blog is part of how you market yourself for remote roles, owning the rendering pipeline and the SEO output is worth a weekend. You control the canonical, the structured data, the OG image, the headers, and the cache strategy. Hosted CMS vendors do most of that, but never all of it.
Md. Tausif Hossain leads engineering at DevTechGuru and ships SaaS independently as TechnicalBind. He is currently open to remote Senior and Staff roles. Reach him at tausif1337.dev.
Newsletter
Get new posts in your inbox.
Honest essays on engineering, leadership, and the things I’m figuring out. No spam, ever.