I gave Claude an API to post to my dev blog (and the bcrypt hash broke in 2 different places) A developer built an API endpoint for their personal blog that allows Claude to publish posts via curl commands, but encountered two bcrypt hash parsing bugs in Next.js. The `@next/env` loader performed variable expansion on `$` characters within bcrypt hashes and ignored single-quote escaping, causing the hash to collapse into empty strings. The developer resolved the issue by either escaping `$` characters with backslashes or bypassing bcrypt entirely with a raw API key and constant-time string comparison. It started with a side project: a personal blog. I wanted somewhere to write that wasn't a hosted platform, with the dark-mode-by-default and the URL structure and the editor I'd been craving. Then halfway through building it, a thought: What if I could just tell Claude "publish a post about X"and it actually shows up on my blog? 48 hours later, here we are. There are two ways to write on the blog: POST /api/ai/posts with an X-API-Key headerPath 2 is the new thing. The key idea: every Claude Code session I run from the terminal already has the API key in my env. So I can just do: curl -X POST http://localhost:3210/api/ai/posts \ -H "X-API-Key: $BLOG AI API KEY" \ -H "Content-Type: application/json" \ -d '{"title":"...","body markdown":"...","status":"draft"}' And a draft or published post appears on the blog. No browser. No copy-paste. Next.js 16 App Router + React 19 + TypeScript Tailwind v4 dark-first File-based JSON store will swap to Postgres later Bcrypt for the API key Zod for input validation I'm intentionally not using a database yet. A .data/posts.json file works fine while I'm the only writer. Trading 30 lines of complexity for "I'll migrate when it matters." The handler lives at app/api/ai/posts/route.ts . It exports four functions for four verbs: export async function GET req { / list / } export async function POST req { / create / } export async function PUT req { / update / } export async function DELETE req { / delete / } The shared first step in each is auth: async function requireAuth req: NextRequest { if apiKeyConfigured { return jsonError 503, "API key not configured" ; } const ok = await verifyApiKey req.headers.get "x-api-key" ; if ok return jsonError 401, "Invalid or missing X-API-Key" ; return null; } Then the verb-specific logic does the obvious thing — parse JSON, validate with zod, call the store, return JSON back. The clever bit is in the store: when Claude POSTs a new post, the slug is auto-generated from the title. If that slug is taken, it appends -2 , -3 , etc. So Claude can call POST repeatedly with similar titles without worrying about collisions. This took me 30 minutes to figure out and I want it documented somewhere so I never fall for it again. I generated an API key, hashed it with bcryptjs , dropped both into .env.local : BLOG AI API KEY HASH="$2b$10$yNATBgF..." Restarted the server. Called the endpoint. Got 503: API key not configured . That's the error I throw when process.env.BLOG AI API KEY HASH is undefined. But the env file has it. What gives? Turns out Next.js's @next/env loader does variable expansion inside double-quoted values. Bcrypt hashes start with $2b$10$ and contain more $ characters. The loader sees $2b , $10 — those look like variable references to it. They don't exist as actual env vars, so they expand to empty strings, and your hash collapses to garbage. I tried single quotes: BLOG AI API KEY HASH='$2b$10$yNATBgF...' Same problem. Next.js's loader doesn't respect single-quoting the same way standard dotenv does — that's the second gotcha. In a vanilla dotenv.config call, single quotes prevent expansion. In Next.js they don't. Two fixes that actually work: BLOG AI API KEY HASH="\$2b\$10\$..." BLOG AI API KEY , do a constant-time string compare. The .env.local is gitignored anyway.I went with option 2 locally and option 1 via Vercel's env-var UI, which doesn't interpret $ in production. The verifier is bilingual: export async function verifyApiKey rawKey: string | null { if rawKey return false; const hash = process.env.BLOG AI API KEY HASH; if hash return bcrypt.compare rawKey, hash ; const raw = process.env.BLOG AI API KEY; if raw return timingSafeEqual rawKey, raw ; return false; } Prefers the hash. Falls back to raw. Works everywhere. Honestly, the most common use case so far is during the writing loop itself. I'll be in the terminal with Claude Code, talking through an idea. When it's good, I ask Claude to draft it and POST it as a draft. Then I open the admin in my browser and finish it in the split-view editor. That's the whole thing: AI handles the rough cut, I handle the edit. Same as code review, but for prose. The other surprisingly nice flow is updates. Halfway through writing a post I'll realize I want a fourth section. I describe it. Claude PUTs the update. I refresh the editor — there it is. The AI is operating inside my draft, not next to it. canonical url pointing back to my blog. SEO win. /api/ai/profile endpointThe architecture is uncomplicated enough to lift in an afternoon: If you build something similar — or if you've found a saner way to handle bcrypt hashes in .env — I want to hear about it. The comments are open.