SAZA
All posts

28 June 2026

How I built a secure admin + blog on Next.js, Neon, and Vercel

How I built a secure admin + blog on Next.js, Neon, and Vercel

This site has a small content engine behind it: a single-admin panel where I write posts, a Postgres-backed blog, and a contact inbox. No CMS subscription, no third-party backend - just Next.js, a serverless database, and a few security details that actually matter.

Here's how it fits together, and the one gotcha that cost me an hour.

The stack

LayerChoice
FrameworkNext.js (App Router)
DatabaseNeon (serverless Postgres)
Authbcrypt + signed JWT cookie
StylingTailwind CSS
HostingVercel + Cloudflare CDN

Everything runs on free tiers, and the whole thing deploys on a git push.

Auth that isn't a toy

For a one-person site you don't need user accounts - you need one well-guarded door. So the model is deliberately small:

  • The password is never stored in plaintext. Only a bcrypt hash lives in an environment variable.
  • A successful login mints a short-lived JWT, signed server-side, stored in an httpOnly, Secure, SameSite cookie.
  • Edge middleware verifies that cookie on every /admin request before the page renders.
  • Every server action re-checks the session too - defense in depth.
// Verify a login attempt without leaking which field was wrong.
const ok = await bcrypt.compare(password, process.env.ADMIN_PASSWORD_HASH!);
if (!ok) return { error: "Invalid email or password." };

The error message is intentionally vague. Telling an attacker "wrong password" (vs "unknown email") hands them a list of valid emails for free.

Neon: Postgres that thinks in HTTP

Neon's serverless driver talks over HTTP, which is exactly what you want on the edge - no connection pool to babysit. The part I care about most is that its tagged-template queries are parameterized by default:

const rows = await sql`SELECT * FROM blogs WHERE slug = ${slug}`;

That ${slug} is not string interpolation - it's a bound parameter. Which means SQL injection isn't a thing I have to think about, as long as I never build queries by gluing strings together.

The safest security feature is the one that's the default. Parameterized-by-default beats "remember to sanitize" every time.

Markdown without the XSS

Posts are written in Markdown and rendered with a parser that doesn't allow raw HTML. So even though the post body is technically user input (my input, but still), there's no path for a <script> to sneak through into the page. The trust boundary stays small.

The gotcha that cost me an hour

Local logins kept failing with a perfectly correct password. The cause was almost invisible: Next.js expands $ in .env files.

A bcrypt hash looks like $2b$12$abc... - three $ signs. The env loader read each $2b, $12, etc. as a variable reference and quietly mangled the hash. So bcrypt.compare always returned false.

The fix is to escape every $ as \$ in .env.local:

ADMIN_PASSWORD_HASH="\$2b\$12\$abc..."

And the twist: in the Vercel dashboard, env vars are stored literally - so there you paste the raw hash with no backslashes. Same value, two different encodings, depending on where it lives. That asymmetry is exactly the kind of thing that eats an evening.

SEO came for free

Because it's all server-rendered, the SEO layer is just metadata: per-page titles and canonical URLs, a dynamic sitemap.xml that reads straight from the database, robots.txt, JSON-LD structured data, and auto-generated social cards. No plugin - the framework already does it.

Takeaways

  • For a personal site, one guarded door beats a full auth system.
  • Let the database's defaults do your security work (parameterized queries, httpOnly cookies).
  • Keep the trust boundary tiny: no raw HTML, generic errors, secrets only in env.
  • And budget an hour for an environment-variable mystery. There's always one.

The whole thing is small enough to understand in an afternoon, which is the real reason I like it. If you're building your own, steal any of this.