The source for blog.coada.dev. A minimal personal
blog: typography-first, zero client-side JavaScript on every page that doesn't
need it, light + dark via prefers-color-scheme, deployed as static HTML to
Vercel.
Content is authored on Hashnode and pulled in at build time over GraphQL. The site itself is a presentation layer — Hashnode owns drafting, scheduling, and the editor; this repo owns rendering.
| Layer | Choice |
|---|---|
| Static generator | Astro 6 |
| Adapter | @astrojs/vercel 10 |
| Content source | Hashnode GraphQL (gql.hashnode.com) |
| GraphQL client | graphql-request |
| Newsletter | Kit (formerly ConvertKit) |
| Styling | One vanilla CSS file (~660 lines) |
| Hosting | Vercel + custom subdomain |
| Analytics | Vercel Web Analytics |
No CSS framework, no JS framework, no build tooling beyond what Astro ships.
The full architecture and rationale lives in
PROJECT_SPEC.md, including the eight ADRs that shaped
the major decisions.
Hashnode has a great editor, drafts, scheduling, subscribers, and CDN-hosted images. It also has a default theme that I didn't want to use. Headless mode keeps the parts I want and lets me own the frontend completely.
The data flow:
┌───────────┐ GraphQL (build-time) ┌─────────┐
│ Hashnode │ ◄──────────────────────────│ Astro │
│ (headless)│ │ (build) │
└───────────┘ └────┬────┘
│
astro build
│
┌──────▼──────┐
│ dist/ │
│ static │
│ HTML + CSS │
└──────┬──────┘
│
Vercel
│
┌──────▼──────┐
│ blog. │
│ coada.dev │
└─────────────┘
Publishing on Hashnode fires a webhook into a Vercel deploy hook, which
re-runs astro build. The new post is live in about a minute.
.
├── astro.config.mjs # site URL + sitemap + Vercel adapter
├── public/
│ ├── assets/coada.svg # logo (with embedded gradient fills)
│ └── robots.txt
├── src/
│ ├── components/ # SiteHeader, SiteFooter, PostList,
│ │ # PostMeta, SeriesNav, AdjacentNav,
│ │ # Tags, SubscribeForm
│ ├── layouts/BaseLayout.astro
│ ├── lib/
│ │ ├── client.ts # GraphQL client
│ │ ├── data.ts # paginated fetchers
│ │ ├── jsonld.ts # WebSite/Org/Person/BlogPosting graph
│ │ ├── queries.ts # GraphQL strings
│ │ └── site.ts # author + site constants
│ ├── pages/
│ │ ├── index.astro # post listing
│ │ ├── about.astro
│ │ ├── [slug].astro # post pages (getStaticPaths)
│ │ ├── series/[slug].astro # series landing pages
│ │ ├── api/subscribe.ts # the only server route — Kit proxy
│ │ └── rss.xml.ts
│ └── styles/global.css # the entire stylesheet
└── PROJECT_SPEC.md # architecture spec + ADRs
git clone https://github.com/coada-dev/blog.git
cd blog
cp .env.example .env # fill in the values described below
npm install
npm run dev # serve on http://localhost:4321
npm run build # produces dist/ and .vercel/output/Astro 6 requires Node ≥ 22.12.
| Variable | Required | What it does |
|---|---|---|
HASHNODE_PUBLICATION_HOST |
always | e.g. yourblog.hashnode.dev — the GraphQL host arg used at build time. |
SITE_URL |
always | Canonical site URL. Drives RSS, OG, JSON-LD, sitemap. |
KIT_API_KEY |
for /api/subscribe |
Server-side Kit secret. Generate at Kit → Settings → Developer. Never reaches the client. |
KIT_FORM_ID |
for /api/subscribe |
Numeric id of the Kit form to attach subscribers to. |
There is no Hashnode access token — the public GraphQL endpoint exposes published posts without authentication, and newsletter is on Kit.
- Posts — every
getStaticPathswalk hits Hashnode'sposts(first, after)connection, paginated 20 per page (Hashnode's hard cap), untilpageInfo.hasNextPageis false. - Body rendering —
content.htmlis injected via Astro'sset:html(see ADR-006 in the spec). Hashnode pre-highlights code blocks and emits embed markup for%[url]shortcodes, so we don't run a remark pipeline. - Series — handled the same way;
series.postsis paginated separately and ordered byseries.sortOrder. - RSS —
/rss.xmlre-fetches each post's full HTML and emits it as<content:encoded>, so feed readers don't need a click-through.
There's exactly one server-rendered route: /api/subscribe.ts. Everything
else is prerendered HTML.
browser ── POST /api/subscribe (email + utm payload)
↓
Vercel serverless function
↓
1. POST https://api.kit.com/v4/subscribers (upsert; 201 new, 200 existing)
2. POST /v4/forms/{form_id}/subscribers/{sub_id} (attach to form)
↓ both calls carry a ?utm_*-encoded `referrer` so Kit records attribution
Kit
Kit's by-email add-to-form endpoint returns 404 on Designer-style forms; the two-step flow is the workaround. The handler logs upstream errors with email addresses redacted.
| Thing | Where |
|---|---|
WebSite/Organization/Person JSON-LD |
every page (one @graph) |
BlogPosting JSON-LD |
post pages (extra graph node) |
article:* OG meta cluster |
post pages |
<meta name="author"> |
every page |
| Sitemap | /sitemap-index.xml, generated by @astrojs/sitemap |
| Robots | public/robots.txt |
| Analytics | Vercel Web Analytics (no consent banner needed) |
| First-touch UTM attribution | Inline script in BaseLayout → localStorage → forwarded to Kit on subscribe |
The geo signal (Los Angeles, CA) lives in Person.address inside the
JSON-LD graph — invisible to readers, available to crawlers. There is no
keyword stuffing in body copy on purpose; it doesn't help technical content
rank, and it dilutes editorial voice.
The first time:
- Create a Vercel project pointing at the repo. Vercel auto-detects Astro and
sets
astro build/dist/correctly. - In Settings → Environment Variables, add the four env vars above for both Production and Preview.
- In Settings → Git → Deploy Hooks, create a hook on
main. Copy the URL. - In Hashnode (Settings → Webhooks), point a webhook at the deploy hook URL. Subscribe to the publish/update/delete events.
- In Hashnode (Settings → Domain → Advanced → Headless CMS), turn on headless mode and point it at your custom subdomain.
After that, publishing on Hashnode triggers a Vercel rebuild within ~60s.
Pushing code to main rebuilds via the standard Git integration.
Lighthouse on desktop, all four categories:
| Route | Performance | Accessibility | Best Practices | SEO |
|---|---|---|---|---|
| Index | 100 | 100 | 100 | 100 |
| Post | 100 | 100 | 100 | 100 |
| Series | 100 | 100 | 100 | 100 |
| About | 100 | 100 | 100 | 100 |
The colour palette clears WCAG AA contrast in both light and dark modes.
MIT © Mike Dyer.
The code in this repo is MIT — fork it, lift the architecture, or copy the
styles. The post content (under listenrightmeow.hashnode.dev) is not in
this repo; it lives on Hashnode and is mine.
- Astro for being the right shape for content sites.
- Hashnode for a headless mode that gets out of the way.
- Kit for newsletter plumbing without bloat.
- Vercel for hosting + analytics.
- mitchellh.com and t3.gg/blog for proof that "extreme restraint" is a perfectly fine design system.