building my own cms
i had blog posts scattered everywhere. medium drafts. a wordpress site. notes on my phone that were secretly blog posts. google docs i'd forgotten about. i wanted one place to write, one place to hit publish, and no platform owning my words.
so i built my own cms into this site.
this post is the full story of that build. what i chose. what i got wrong. what i had to tear out and redo. if you're thinking about doing something similar — a portfolio cms, a self-hosted blog, something that's yours — i hope the specifics here save you some time.
why not just use something existing
i looked at the options.
wordpress — too heavy, too many moving parts, not what i wanted to maintain for a personal site.
ghost — nice but felt like overkill, and i didn't want to run another service.
a flat-file setup with markdown in a github repo — close to what i wanted, but editing meant opening a code editor every time. no image management. no drafts. and if i'm writing from my phone i'm stuck.
notion as a cms — great for drafting, but the rendering and sync story is brittle.
i wanted the writing experience of a real admin ui, the control of owning the code, and the portability of markdown if i ever needed to leave. building it myself was the only way to get all three.
the first version
the first iteration came together in a few days. the stack:
- next.js 16 app router, same app as the public site
- sqlite via
better-sqlite3, file atdata/blog.db - drizzle orm for schema and queries
- plate.js for the rich text editor (serializes to json, deserializes from markdown)
- local disk for uploaded images at
data/uploads/YYYY/MM/ - sharp for image optimization (resize, convert to webp, generate thumbnails)
admin pages at /cms:
- dashboard with post stats
- post list, editor, create/edit/delete
- media library with drag-drop upload
- categories and tags management
- settings for the ai providers
- export to mdx (zip of posts + images)
- ai writing features: rewrite, expand, shorten, translate, alt-text, outline
it worked. it felt fast. i published a couple of posts and thought i was done.
the wake-up call
then i actually looked at deploying to vercel.
vercel's filesystem is ephemeral. every time you deploy new code, the container gets replaced. anything written to disk during the previous deploy is gone. this is fine for logs and temp files. it is not fine for a database or user uploads.
so: my sqlite database at data/blog.db would vanish on the first code push after deploy. every blog post, every tag, every category — wiped. and any images uploaded via the cms would also disappear, but the database references to them would stay, giving me broken images pointing at files that don't exist.
i audited the rest of the app and found two more problems:
/cmshad no authentication. the routes were public. anyone who found the url could create, edit, or delete any post. i hadn't added auth because "it's just me and i haven't deployed yet."- the blog had a weird inconsistency.
generateStaticParamsat build time plusgetAllPostsat runtime meant that once a post was built, editing it in the cms wouldn't update the public page until the next full redeploy.
these weren't bugs in the "the code is broken" sense. the code did what it said. i had just shipped it with production-incompatible choices baked in from day one.
i wrote a plan to fix all three. four phases, one per migration, testable independently.
phase one — turso (libsql) replaces sqlite
the choice. turso runs libsql (a fork of sqlite with network protocol support). same database engine, same sql, same drizzle schema. the change from "local file" to "remote database" is one import swap and one connection string. for local development i can still point at a file — file:./data/blog.db — and it works without running turso at all.
the catch. better-sqlite3 is synchronous. @libsql/client is asynchronous. every single database call in the codebase had to change.
a before/after:
// before
const posts = db.select().from(postsTable).where(...).all()
const tagNames = posts.map(p => p.tagName)
// after
const posts = await db.select().from(postsTable).where(...)
const tagNames = posts.map(p => p.tagName)
looks like two characters of difference. except every caller of these functions needed to become async too. functions that used to return values now returned promises. components that called them needed to be server components that awaited the data.
src/lib/blog.ts exported five functions: getAllPosts, getAllSlugs, getAllTags, getPostBySlug, getSearchableContent. all synchronous. all used in blog pages. making them async cascaded into changing BlogPage from function to async function, changing generateStaticParams from a plain function to an async one, and so on.
the mechanical work took about an hour. the typescript errors walked me through it — each Property 'map' does not exist on type 'Promise<...>' pointed at the next missing await. i fixed the obvious ones, ran yarn build, got a fresh list, repeated.
the gotchas.
drizzle.config.ts needed a dialect change: "sqlite" → "turso". without this, drizzle-kit push refuses to connect to a libsql url.
the serverExternalPackages array in next.config.ts had to drop better-sqlite3 and add @libsql/client and libsql. otherwise the bundler tries to pull the native modules into the edge runtime and breaks.
at the end, pushing the schema:
turso db create juunidev
turso db tokens create juunidev
TURSO_DATABASE_URL=... TURSO_AUTH_TOKEN=... yarn db:push
clean migration. no data transfer yet — that came later.
phase two — cloudflare r2 replaces local uploads
why r2. the standout feature: zero egress fees. s3 charges about $0.09 per gb of traffic out. r2 charges $0. for a blog where images might get re-requested a lot (reader reloads, social share previews fetching og images, etc), this matters. r2's other win is that it's s3-compatible, so i could use @aws-sdk/client-s3 without learning a new api.
the new flow.
before, uploading an image was:
- receive file via
POST /api/cms/mediawith form-data - validate mime type + size
- write raw bytes to
data/uploads/YYYY/MM/{nanoid}.{ext} - run sharp on the disk file — resize if >1920px, convert to webp, quality 80
- write the webp back to disk
- generate a 400px-wide thumb, also webp, quality 75
- write the thumb next to the main
- insert a row into the
mediatable with the stored path
after:
- receive file, validate
- read raw bytes into a
Bufferin memory - run sharp on the
Buffer— same optimizations, returns anotherBuffer - generate thumb
Bufferfrom the optimized main putObjectboth buffers to r2 at{year}/{month}/{nanoid}.webpand{...}_thumb.webp- insert the media row with
stored_path(the r2 key) andpublic_url(the cdn url)
the storage layer became mode-aware. if the r2 env vars are set, it talks to r2. if they're not, it falls back to the original disk behavior. this lets me develop offline without r2, and the code path in production is identical except for where the bytes land.
serving images. every existing image reference in the database and in post bodies pointed at /api/cms/media/{id}. i didn't want to rewrite every post. so the /api/cms/media/[id] route now does one of two things:
- in r2 mode: look up the
public_urlfrom the database and return a307 redirectto it. the browser follows the redirect, fetches from r2's cdn, never talks to my server again for that image. - in disk mode: read bytes from disk and stream them (original behavior).
this meant zero changes in every other place that references images. old urls still work. new urls still work. a reader loading a blog post pulls the image through a one-time redirect, then their browser caches it with the immutable cache-control from r2.
custom domain vs public dev url. r2 gives you a pub-xxxxxx.r2.dev url out of the box for public buckets. it works but it's ugly and cloudflare rate-limits it. the proper setup is to attach a custom domain — i used pub-b74eaf8e038c47c5b176e0ee40878f64.r2.dev for now since i wanted to ship, and i can move to a branded subdomain later by changing one env var.
phase three — iron-session for auth
the design. single user. me. no signup, no password reset flows, no user table. the only admin state that matters is "is this request authenticated."
i used iron-session (encrypted session cookie) and bcryptjs (password hashing). the password itself lives as a hash in an env var. to log in, you submit a form, the server action compares the submitted password against the hash, and if it matches the session cookie gets set with { userId: "owner" }.
the whole auth layer is under 150 lines across four files:
src/lib/session.ts— session options (cookie name, secret, lifetime) and agetSessionhelpersrc/app/login/page.tsx+src/app/login/login-form.tsx— the login uisrc/app/login/actions.ts— theloginActionandlogoutActionserver actionssrc/proxy.ts— the route guard
proxy vs middleware. this one caught me. in next.js 16, the file formerly known as middleware.ts was renamed to proxy.ts. same concept, same api shape, different filename. i didn't catch this in the changelog and spent a confused half hour wondering why my middleware wasn't running.
the proxy checks each request. if the url starts with /cms or /api/cms, it reads the session cookie and verifies it. if valid, it passes through. if not, /cms routes redirect to /login and /api/cms routes return a 401 json response.
one route escapes the gate: GET /api/cms/media/:id. this stays public so blog readers can load images. everything else is locked.
a lesson in reading library source. iron-session's getIronSession helper has multiple call signatures. one takes a next.js cookies() store. another takes separate request and response objects. i tried passing req.cookies and res.cookies from NextRequest/NextResponse and got the cryptic error Cannot read properties of undefined (reading 'cookie'). the types checked but the internals didn't — the NextRequest cookies api doesn't match what iron-session reaches for at runtime.
the fix was to bypass the helper and call unsealData directly on the cookie value string:
const cookie = req.cookies.get("juuni_session")?.value
if (!cookie) return false
const session = await unsealData<Session>(cookie, { password })
return Boolean(session.userId)
thirty minutes to figure out. five minutes to fix. the usual.
phase four — isr with revalidatepath
the blog pages need to be fast. pre-rendering every published post at build time gives fast cold loads. but content changes in the cms — new posts, edits, published/unpublished toggles — need to reflect on the public site without a redeploy.
next.js calls this pattern incremental static regeneration. i read the docs (via the next devtools mcp — the docs confirmed this is the exact pattern for a cms-backed blog) and the recipe is:
- keep
generateStaticParams— it pre-renders all known posts at build time - set
dynamicParams = true(the default) — posts not known at build time generate on first request - set
export const revalidate = 3600— time-based safety net, re-renders after 1 hour regardless - in every cms mutation, call
revalidatePath('/blog')andrevalidatePath('/blog/${slug}')— on-demand revalidation when content actually changes
so: publish a post in the cms → a server action writes the row and immediately tells next.js "the /blog/post-slug page is stale now." the next request to that page rebuilds it fresh. readers see the update within seconds. no deploy needed.
the server actions look like:
export async function publishPost(id: string) {
const post = await db.query.posts.findFirst({
where: eq(posts.id, id), columns: { slug: true }
})
await db.update(posts)
.set({ status: "published", publishedAt: new Date() })
.where(eq(posts.id, id))
revalidatePath("/blog")
if (post?.slug) revalidatePath(`/blog/${post.slug}`)
}
every mutation — create, update, delete, publish, unpublish, tag changes, category changes — fires the relevant revalidatePath calls. cache is always consistent with the database.
the small things that took too long
the $ in .env.local. my bcrypt hash starts with $2b$10$.... next.js' env parser does variable substitution on $var sequences — and my hash was parsed as "literal $2b + empty variable $10 + empty variable $nyMAjCE...." the env var came out mangled. the password compare failed. i stared at the logs wondering why bcrypt was broken.
the fix: escape each $ with a backslash in the env file:
AUTH_PASSWORD_HASH=\$2b\$10\$i4wKNW8tQd8mVnFlAnFA1O.pbtoQqhXGTyUi.NSqdXJodg7OhXrLW
on vercel's dashboard you paste the raw value and it stores it correctly. this is only a .env.local problem.
invalid test pngs. i wanted to programmatically test the image upload. i hand-wrote a 1x1 red png by typing the bytes of the png spec into a node script. sharp rejected it: pngload_buffer: libspng read error. turns out my hand-written png was missing required chunks and the crc was wrong.
the fix was to use sharp itself to generate a real png for the test:
await sharp({ create: {
width: 200, height: 200, channels: 4,
background: { r: 255, g: 100, b: 50, alpha: 1 }
}}).png().toFile("/tmp/test.png")
worked immediately. lesson: when testing an image pipeline, generate test images with the same library.
silent fallback to ephemeral state. the storage and database layers both have env-gated fallbacks. if TURSO_DATABASE_URL isn't set, the app uses file:./data/blog.db. if the r2 vars aren't set, it writes to local disk. this is great for local development. but on vercel, if you forget to set these env vars, the app deploys and runs — but with ephemeral filesystem state. no error. just silent data loss on the next deploy.
the mitigation: a deployment checklist that verifies every env var is present. and ideally a startup check that logs a warning if we're running in production with any of these unset. i haven't added the startup check yet. on the todo list.
migrating the actual data
once all four phases were done and tested, i still had to move my existing 10 posts and 5 images from local sqlite + disk into turso + r2.
i wrote a one-shot migration script at scripts/migrate-to-prod.ts that:
- reads all rows from
data/blog.db - for each
mediarow, reads the file fromdata/uploads/, uploads to r2, generates thepublic_url - inserts all rows into turso with
insert or replace(so the script is idempotent)
the script handles edge cases: missing files, existing tags, existing post_tags. it logs a line per record. it exits with a count summary. i ran it once, verified counts, moved on.
Turso final counts:
posts: 10
categories: 0
tags: 18
media: 6
post_tags: 24
the test image i uploaded during r2 verification counted as the 6th media file. everything else came from the local db.
what the stack looks like now
- framework: next.js 16.2 app router + turbopack
- database: turso (libsql via
@libsql/client) + drizzle orm for schema + queries - storage: cloudflare r2 via
@aws-sdk/client-s3, served from a custom domain (cdn cached) - image optimization: sharp, in-memory buffer pipeline
- auth: iron-session + bcryptjs, enforced via next.js 16 proxy
- caching: isr with on-demand
revalidatePathfired from cms server actions - editor: plate.js, with imports from and exports to markdown
- styling: tailwind v4 + custom "kinetic brutalism" css tokens for the public site
- ai: multi-provider sdk (anthropic, google, groq, deepseek, openrouter) via vercel ai sdk
all of this is in one repo. one deploy. nothing hidden behind a third-party admin ui.
takeaways
write the deployment checklist before building features. if i had spent 30 minutes mapping out "what happens on vercel" on day one, i would have chosen turso and r2 from the start and saved myself the migration. the feature work felt productive because i was shipping things i could see; the durability work wasn't visible until the whole thing almost fell over.
prefer async from day one. better-sqlite3 is fast, ergonomic, easy. but it locked me into sync code that had to be refactored wholesale. if you might ever host your data remotely, start async. the cost is tiny, the optionality is huge.
silent fallbacks are a trap. my local-disk and file-db fallbacks made development easy, but they also meant production misconfiguration would never surface as an error. next step is adding a runtime check that screams if we're running in production with ephemeral storage.
read the changelog. middleware.ts → proxy.ts is a small rename. reading the next.js 16 release notes would have saved me the "why is my middleware not running" debug session.
own the code. the thing that keeps me happy with this build isn't the cms features. it's that when something breaks, i just open the file, see what's happening, and fix it. no waiting on a platform, no filing a support ticket, no workaround for someone else's abstraction. owning the code is worth every hour of refactor.
the loop
i think of something to write. i open /cms. i write. i click publish. a few seconds later it's on the public blog. if i later edit the post, the public page updates within seconds. if i want to move the whole thing off vercel, i export to mdx and go. if someone wants to read it, they load a pre-rendered html page and the images stream from cloudflare's cdn.
that's the whole thing. nothing more, nothing less. and it's mine.