I spent about a day working on impelemnting comments on the site. Why? One simple reason - traffic! If you’ve been following my journey with Astro, the whole reason I chose it was because it’s optimized for SEO traffic. What it isn’t optmized for is integrating a commens section but honestly, it wasn’t that hard. Here’s what I did…
I built a serverless comments system for this site using Astro + Supabase and shipped a small moderation workflow with email notifications. This post walks through the approach, implementation highlights, and every real-world hiccup I encountered — plus the concrete fixes I used so you can reproduce the setup without repeating these mistakes.
Why this approach
- Astro provides a fast static content site with lightweight server routes (I use
src/pages/api/*). - Supabase gives us Postgres, RLS and a familiar SQL surface without managing a DB server.
- Server-side email (I used Resend + Nodemailer) sends notifications on new comments.
- Admin moderation uses a tiny HMAC-signed cookie token rather than a full user system.
Architecture overview
- Public UI:
CommentFormposts toPOST /api/comments. - Server endpoints:
GET /api/comments(public — only approved comments),POST /api/comments(validate + insert), and admin endpoints under/api/admin/*for login/logout/moderation. - Storage: Supabase
commentstable (with anapprovedboolean controlling public visibility). - Email: server helper at
src/lib/email.tsinvoked on new comment creation.
What I implemented (short list)
- Server-side insert path guarded by a server-only Supabase client (
supabaseAdmin). - Admin tokens signed with HMAC and optional scrypt-based KDF (via
ADMIN_PEPPER). - Short-lived
admin_tokencookie (30 minutes) with HttpOnly, SameSite=Strict, and Secure flags. - Honeypot field in the comment form plus server-side check.
- Lightweight in-memory rate limiter to protect endpoints (admin login and comment POST).
- Admin UI styled with the site
BaseLayoutfor consistency. - Migration plan to remove
service_rolefrom app code and move writes to a least-privilege RPC or Edge Function (docs/RPC_EDGE_FUNCTION_PLAN.md).
Detailed walkthrough & hiccups
Below are the real surprises I hit while building and how I fixed them.
- Body parsing gotcha: PowerShell and curl
- Problem: When testing endpoints from PowerShell or various clients I sometimes received malformed JSON or unexpected form-encoded payloads which triggered 400/500 responses.
- Fix: Make the server tolerant: accept
application/jsonandapplication/x-www-form-urlencoded, normalise multiple field names, and validate inputs early. When using PowerShell, prefer Invoke-RestMethod with explicit ContentType and JSON conversion:
Invoke-RestMethod -Method Post -Uri http://localhost:3000/api/comments -Body (ConvertTo-Json @{ author = 'You'; content = 'Hi' }) -ContentType 'application/json'
Or with curl:
curl -X POST -H "Content-Type: application/json" -d '{"author":"You","content":"Hi"}' http://localhost:3000/api/comments
- Route and 404 surprises
- Problem: Astro’s endpoints can return 404s when route naming or file placement is off, which sometimes hid actual parsing errors.
- Fix: Consolidate into a single
comments.tshandler that switches on request method. Add dev logging (method + url) to make errors visible while iterating.
- Cookie Secure flag vs local dev
- Problem: Setting the
Securecookie flag blocks cookies over plain HTTP (local dev), leading to confusion when cookies appeared not to be set. - Fix: Enforce
Securein production. For local development, either run with local HTTPS (recommended via mkcert) or temporarily allow non-secure cookies (do not leave this in production).
- Admin token signing and optional pepper (KDF)
- What I did:
src/lib/admin-token.tssigns tokens with HMAC-SHA256 usingADMIN_SECRET. IfADMIN_PEPPERis set, a derived key is produced withscryptSync(ADMIN_SECRET, ADMIN_PEPPER, 32). - Why: Using a pepper + KDF makes a signing key harder to reconstruct from a leaked secret.
- Note: Store
ADMIN_PEPPERandADMIN_SECRETin your host secret manager — not in source.
- Reducing attack window (short-lived admin cookie)
- Change: Token TTL set to 30 minutes. The login endpoint returns
ttlSecondsso clients can show the expiry. - Why: A short TTL reduces the impact of a stolen cookie.
- Honeypot and spam heuristics
- Client:
src/components/CommentForm.astrocontains a visually-hiddenwebsitefield that real users will not fill. - Server:
src/pages/api/comments.tsrejects submissions where the honeypot has a value. - Why: Low-effort spam defense that keeps UX friction to zero. Combine with length checks and heuristics for higher coverage.
- Rate limiting
- Implemented
src/lib/rate-limiter.ts(in-memory) and used it to throttle comment POSTs and admin login attempts (e.g., 10 comments per 10 minutes; 5 login attempts per 15 minutes). - Caveat: In-memory rate limiting is not suitable for multi-instance production — use Redis or host-managed rate-limits for production deployments.
- Why I still used a
service_rolekey, and how to remove it
- Short-term:
supabaseAdmin(service_role) used in server code to insert comments and perform admin actions. - Long-term: see
docs/RPC_EDGE_FUNCTION_PLAN.md— two recommended approaches:- Postgres SECURITY DEFINER RPC that sanitises and inserts comments and is callable by anon via a constrained RPC.
- Supabase Edge Function that validates, inserts and handles side-effects, keeping the service key in the function environment only.
- Email notifications
- I used Nodemailer with Resend SMTP to send a notification email on new comments. The helper is
src/lib/email.tsand readsRESEND_API_KEYfrom environment. - Tip: Test using the provider’s UI/logs. If mails don’t arrive, check the SMTP logs and server error output.
- Secrets & repo hygiene
- I found
.env.localcontained keys — a critical risk. - Action: add
.env.localto.gitignore, rotate any leaked keys (Supabase service_role, Resend API, ADMIN_SECRET), purge sensitive values from history withgit-filter-repoor BFG, and update the deployment secrets. - Generate secure secrets with these commands:
PowerShell (Windows):
$bytes = New-Object 'System.Byte[]' 32; (New-Object Security.Cryptography.RNGCryptoServiceProvider).GetBytes($bytes); [Convert]::ToBase64String($bytes)
OpenSSL (macOS / Linux):
openssl rand -base64 32
Testing & verification
- End-to-end checklist: POST a comment (public) → row in
commentstable → admin receives notification → admin logs in via/admin→ approve the comment → public GET returns approved comment. - Dev notes: Restart dev server (
ntl devornpm run dev) after applying code changes. If cookie behavior is unexpected, verify HTTPS and cookie flags.
Files to review
- `src/pages/api/comments.ts` — GET/POST + spam checks
- `src/pages/api/admin/login.ts` & `logout.ts` — admin token and cookie handling
- `src/pages/admin/index.astro` — moderation UI (uses `BaseLayout`)
- `src/components/CommentForm.astro` — client form with honeypot
- `src/lib/admin-token.ts` — HMAC + optional scrypt KDF logic
- `src/lib/rate-limiter.ts` — in-memory rate limiter
- `src/lib/supabase-server.ts` — server-only Supabase client (service_role)
- `docs/RPC_EDGE_FUNCTION_PLAN.md` — migration plan for RPC vs Edge Function
Final notes & recommended next steps
- Rotate the service_role and API keys immediately if they were ever committed.
- Remove the service_role from app code by implementing an RPC or Edge function.
- Replace in-memory rate-limiter with Redis before scaling.
- Add automated E2E tests for the moderation flow and run them in CI.
Comments
Loading comments...
Leave a Comment