Cloudflare Workers, Hono and D1: How This Site Is Built
This site runs as one Cloudflare Worker written in TypeScript, with Hono for routing, D1 for blog posts and contact messages, and a static assets binding for CSS, images and files. There's no admin panel, no client-side framework and no origin server. Pages are rendered as HTML strings on each request, posts live in a single D1 table that I edit with wrangler, and the contact form sends email over SMTP straight from the Worker.
I last updated this post on 24 October 2026, after changing how the translated pages are served, so it matches the code that actually serves tamimiqbal.com today. Earlier versions described a blog admin panel and KV sessions. Both are gone, and I'll explain why. Every snippet below is taken from the real repository and trimmed for length. IDs and secrets are left out.
Key takeaways
- One Worker does everything: routing, HTML rendering, SEO tags, sitemaps, the contact API and email.
src/index.tsis just under 500 lines. - Static files skip the Worker. Cloudflare serves a matching file from
./publicfirst and only invokes the Worker when nothing matches. Cloudflare's pricing page says requests to static assets are free. - D1 holds two tables:
blogsandcontact_submissions. Posts are managed withwrangler d1 execute, not a web UI. - Five languages, one set of routes. English, Bengali, French, Spanish and Arabic share handlers through URL prefixes. Only the English pages are indexed, and the blog stays English only.
- Email goes out over raw TCP. A small SMTP client built on
cloudflare:socketssends notifications on port 465, after the response has already gone back to the visitor.
The stack at a glance
It all fits in a short package.json. There's one runtime dependency, Hono. Everything else is build tooling.
| Layer | What it uses | Why |
|---|---|---|
| Runtime | Cloudflare Workers, nodejs_compat flag | No server to patch. Runs close to visitors |
| Framework | Hono 4 | Small, typed routing and middleware built on Web Standards |
| Database | Cloudflare D1, binding DB | SQLite with no connection strings or pools |
| Static files | Workers static assets, binding ASSETS | CSS, images, motion.js and the web manifest, served without running code |
| Styling | Tailwind CSS 3, built with the CLI | One minified style.css, no CDN script at runtime |
Hand-written SMTP client over cloudflare:sockets | Contact notifications with no extra service SDK | |
| Bot protection | Google reCAPTCHA v3, checked server side | Keeps the form from becoming a spam relay |
Hono earns its place because it stays out of the way. Its documentation says it "has zero dependencies and uses only the Web Standards," and that the hono/tiny preset is under 14KB minified. I use the regular hono import, not the tiny preset, but the point stands: the framework is a thin layer over Request and Response.
How the project is laid out
src/
index.ts Hono app: middleware, every route, sitemaps, contact API
env.d.ts Types for bindings, vars, secrets and BlogPost
style.css Tailwind entry file
data/
profile.ts Repository list shown on the home and projects pages
db/
blog.ts All D1 queries
lang/
en.ts bn.ts fr.ts es.ts ar.ts UI strings per language
middleware/
lang.ts Language detection and URL prefix helpers
services/
smtp.ts SMTP client over cloudflare:sockets
contact-email.ts Owner notification and visitor confirmation templates
recaptcha.ts Server-side token check
templates/
layout.ts HTML shell, meta tags, robots, JSON-LD graph
home.ts resume.ts projects.ts contact.ts ...
blog-list.ts blog-show.ts blog-archive.ts
public/ Static assets (style.css, motion.js, uploads/, _headers)
migrations/
0001_blog.sql
0003_drop_email_marketing.sql
wrangler.toml
Templates are plain functions that return strings. renderLayout(opts, body, langStrings) wraps every page, so the head tags, menu and footer exist in exactly one place. There's no JSX and no virtual DOM. For a site this size, template literals are enough, and they keep the Worker bundle small.
wrangler.toml: bindings, routes and assets
This is the shape of the config with account, zone and database IDs removed:
main = "src/index.ts"
compatibility_date = "2025-05-01"
compatibility_flags = ["nodejs_compat"]
assets = { directory = "./public", binding = "ASSETS", not_found_handling = "none" }
[[routes]]
pattern = "tamimiqbal.com/*"
[[routes]]
pattern = "www.tamimiqbal.com/*"
[[d1_databases]]
binding = "DB"
database_name = "tamimiqbal-blog"
[vars]
SITE_URL = "https://tamimiqbal.com"
SMTP_PORT = "465"
# plus analytics IDs, the reCAPTCHA site key and sender details
The assets line matters more than it looks. According to Cloudflare's static assets routing docs, "Cloudflare will first attempt to serve static assets if one matches the incoming request," and only invokes the Worker when nothing matches. So /style.css or a cover image under /uploads/blogs/ never touches my code. The Workers pricing page says "requests to static assets are free and unlimited," and only Worker invocations count against the Free plan's 100,000 requests per day.
One side effect caught me out. Because assets bypass the Worker, the security headers middleware below doesn't run on them. Cache rules for static files go in public/_headers instead. Mine sets a one-year immutable cache on motion.js, a small progressive-enhancement script. It never hides content on load: scroll reveals only arm after the visitor's first scroll, so crawlers and screenshot tools, which don't scroll, always get the full page. The reverse is also true: Cloudflare's headers docs state that rules in _headers "are not applied to responses generated by your Worker code."
Secrets never go in wrangler.toml. The reCAPTCHA secret, the SMTP username and password, and the IndexNow secret are set with wrangler secret put <NAME>, and env.d.ts declares them so TypeScript knows they exist.
Two middlewares run on every Worker request
One adds security headers after the handler finishes. The second sends www and workers.dev hosts to the bare domain with a 301, so search engines only ever see one hostname.
const app = new Hono<{ Bindings: Env }>();
app.use('*', async (c, next) => {
await next();
c.header('X-Frame-Options', 'SAMEORIGIN');
c.header('X-Content-Type-Options', 'nosniff');
c.header('Referrer-Policy', 'strict-origin-when-cross-origin');
c.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
c.header('Strict-Transport-Security', 'max-age=63072000; includeSubDomains; preload');
});
app.use('*', async (c, next) => {
const host = c.req.header('host') || '';
if (host.startsWith('www.') || host.endsWith('.workers.dev')) {
const url = new URL(c.req.url);
return new Response(null, {
status: 301,
headers: { Location: `https://tamimiqbal.com${url.pathname}${url.search}` },
});
}
await next();
});
An HSTS max-age of 63,072,000 seconds is two years. The workers.dev redirect exists because workers_dev = true keeps a preview hostname alive, and I don't want it indexed as a duplicate.
Multilingual routes without duplicated handlers
Main pages come in five languages: en, bn, fr, es and ar. English lives at the root, and the others sit under a prefix, so /fr/resume is the French resume. Each handler is written once and registered for every prefix:
app.get('/resume', resumeHandler);
(Object.keys(SUPPORTED_LANGS) as LangCode[])
.filter(c => c !== 'en')
.forEach(code => app.get(`/${code}/resume`, resumeHandler));
Inside the handler, detectLang() in middleware/lang.ts works from the URL alone:
- A URL prefix such as
/bnsets the language, and it's stripped to get a clean path. - With no prefix, the page is English. There's no cookie or
Accept-Languageswitching. - Anything under
/blogor/blogsis forced to English.
Earlier versions also checked a site_lang cookie and the Accept-Language header on unprefixed URLs. I removed that in October 2026. When one URL can return different languages, the content stops matching its canonical tag, and a search engine can't know which version it's indexing.
Translated strings are flat key-value objects in src/lang/. Arabic pages get dir="rtl" on the <html> element, and each language maps to an og:locale such as bn_BD or ar_SA. The translations are there for visitors, not for search: every prefixed page sends noindex, follow, no page carries hreflang links, and the translated URLs are left out of the sitemap. The unprefixed English page is the one that gets indexed.
D1 stores posts and contact messages
Schema changes live in migrations/0001_blog.sql:
CREATE TABLE IF NOT EXISTS blogs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
content TEXT NOT NULL,
tags TEXT,
meta_description TEXT,
feature_image TEXT,
is_published INTEGER NOT NULL DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_blogs_slug ON blogs(slug);
CREATE INDEX IF NOT EXISTS idx_blogs_published ON blogs(is_published, created_at);
CREATE TABLE IF NOT EXISTS contact_submissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL,
phone TEXT,
message TEXT NOT NULL,
ip TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
Only one other migration file remains. 0003_drop_email_marketing.sql drops the tables from an email marketing feature I removed. Migration 0002 created those tables, and I deleted its file along with the feature, which is why the numbering skips.
All queries sit in src/db/blog.ts and use prepared statements with bind(). Listing queries select only the columns they need, which keeps rows read, and cost, down:
export async function getPostBySlug(db: D1Database, slug: string) {
const row = await db
.prepare('SELECT * FROM blogs WHERE slug = ? AND is_published = 1 LIMIT 1')
.bind(slug)
.first<BlogPost>();
if (!row) return null;
return withContentHtml(withExcerpt(row));
}
Post content is stored as HTML and rendered as is. There's no Markdown step. Tags can be a JSON array or a comma-separated string, and parseTags() accepts both, because older rows used the second format. Related posts are chosen in code: fetch the other published posts, score each by shared tags, break ties by date and keep the top three.
For pricing, limits, indexes and backups, see my Cloudflare D1 guide. It covers the database side in far more depth than this post.
Why there's no admin panel
The old version of this site had a blog admin at /admin/blog. I removed it in the same cleanup that took out email marketing. A login form on a personal site is attack surface to maintain for something a command line does just as well. Now there's no KV namespace in the config and nothing to log into.
I write posts as HTML files, then push them with Wrangler:
# Publish or update from a SQL file
npx wrangler d1 execute tamimiqbal-blog --remote --file=./post.sql
# Quick one-off change
npx wrangler d1 execute tamimiqbal-blog --remote \
--command "UPDATE blogs SET is_published = 0 WHERE slug = 'old-post'"
Access control is now whoever can authenticate Wrangler against the Cloudflare account, instead of a password form I wrote myself. Try --local first against the local database that wrangler dev uses. It's the cheapest way to catch a broken quote in a long HTML string. I compared this trade-off with a full CMS in custom CMS vs WordPress architecture.
The blog routes
| Route | What it does |
|---|---|
/blogs | Listing, with ?q= search across title, excerpt and tags |
/blog/:slug | One post, with related posts and article meta |
/blog/archive | Posts grouped by year and month |
/blog | 301 to /blogs |
Search runs in the Worker on the list of published posts, not in SQL. With a few dozen posts that's fine. Search result pages get noindex and a canonical pointing at /blogs, because they're thin copies of the listing. A missing slug returns a real 404 with noindex, not a soft 404 with a 200 status.
SEO is built into the layout
layout.ts builds the whole head from one options object. Each page passes a title, description, clean path and language. Blog posts also pass dates, tags and a cover image.
- Canonical and robots: the canonical is derived from the clean path and language. English pages are indexable, and prefixed translations get
noindex, follow. - Open Graph and Twitter cards:
og:typeswitches toarticleon posts and addsarticle:published_time,article:modified_timeand onearticle:tagper tag. Image URLs are made absolute. - JSON-LD graph: one
@graphwith a WebPage node (ProfilePage on the home page), BreadcrumbList, WebSite with a SearchAction pointing at/blogs?q=, Person, an Organization node for Instructorium, and BlogPosting on post pages. Nodes reference each other by@id, so the author of every post resolves to the same Person entity. - Breadcrumbs: posts get Home, Blog, Post. Other pages get Home and the page.
- Titles: the " | Tamim Iqbal" suffix is only added when the full title stays within 65 characters.
Sitemaps are generated on request. /sitemap.xml lists English URLs only: the indexable static pages, then every published post. The lastmod dates are stable. Static pages use a SITE_UPDATED constant that I change when their copy changes, the blog listing and archive use the newest post's updated_at, and each post uses its own updated_at. A date that changes on every fetch tells a crawler nothing. /sitemap-images.xml lists post covers, and /sitemap_index.xml points at both. robots.txt is also a route. It blocks /api/ and lists both sitemaps.
There's an IndexNow integration too. The key file is served at /<key>.txt, and POST /api/indexnow sends every indexable page and published post to api.indexnow.org. That route requires a bearer token, the INDEXNOW_SECRET Worker secret, because an open endpoint would let anyone submit URLs under the site's key. The IndexNow FAQ says a submission "will be shared across all IndexNow-enabled search engines." Google isn't one of them, so Search Console still matters. For the performance side of ranking, see Core Web Vitals and SEO optimization.
Tailwind is compiled, not loaded from a CDN
The live site doesn't load Tailwind from a CDN. tailwind.config.js scans ./src/**/*.ts, because every class name lives inside template strings, and the CLI writes one minified file into the assets folder:
"scripts": {
"dev": "wrangler dev",
"build:css": "tailwindcss -i ./src/style.css -o ./public/style.css --minify",
"deploy": "npm run build:css && wrangler deploy"
}
My config adds brand colors, the Outfit and Space Grotesk fonts, and a float animation. Since deploy always rebuilds CSS first, a template can't ship with a class the stylesheet doesn't have.
The contact form sends email over raw SMTP
POST /api/contact/send takes JSON and works through a fixed sequence:
- Check that name, email and message are present and the email looks valid.
- If a reCAPTCHA secret is configured, verify the reCAPTCHA v3 token server side and reject scores below 0.5. A missing token fails the check, so leaving it out can't bypass it.
- Trim each field to a maximum length and save the row to
contact_submissionswith the visitor's IP. A failed save is logged, not fatal. - Queue an email to me and, only when reCAPTCHA is on, a confirmation to the visitor.
- Return
{ status: 'success' }without waiting for SMTP.
Workers can't use port 25. The TCP sockets docs say "Workers cannot create outbound TCP connections on port 25 to send email to SMTP mail servers." Authenticated submission on port 465 to a mail provider works fine, though. This site uses SendPulse's SMTP host, smtp-pulse.com. services/smtp.ts is about 130 lines. It opens a TLS socket, runs EHLO, AUTH LOGIN, MAIL FROM, RCPT TO and DATA, and gives up after 15 seconds:
import { connect } from 'cloudflare:sockets';
const socket = connect(
{ hostname: cfg.host, port: cfg.port },
{ secureTransport: 'on', allowHalfOpen: false },
);
Message bodies are base64-encoded, which avoids dot-stuffing and line-length rules. Every header built from user input passes through a function that strips CR and LF, so a crafted name can't inject extra headers. The send runs inside c.executionCtx.waitUntil(), which Cloudflare's context docs describe as extending "the lifetime of your Worker, allowing you to perform work without blocking returning a response." The visitor sees success right away, and a slow mail server can't hold the request open.
The auto-reply rule is deliberate. If anyone could make my Worker email any address they typed, the form would become a spam cannon. Tying the confirmation to a passed bot check closes that off.
Deploying
npm run deploy
That compiles Tailwind, then wrangler deploy bundles src/index.ts, uploads changed files from public/ and attaches the routes. Schema changes go through npx wrangler d1 migrations apply tamimiqbal-blog --remote before a deploy that depends on them. Posts don't need a deploy at all, because they're data. A new cover image does, since it ships with the assets folder.
If you're weighing this against a VM or a container platform, I compared the major clouds in modern cloud infrastructure on AWS, Azure and GCP. For a content site with a contact form, a Worker is much less to run.
FAQ
Do you need a framework like Hono on Cloudflare Workers?
No. A Worker can export a plain fetch handler. Hono pays for itself once you have a few dozen routes, middleware that runs everywhere, and typed access to bindings through Hono<{ Bindings: Env }>.
How do you publish a blog post without an admin panel?
I write the post as HTML, wrap it in an INSERT or UPDATE, and run wrangler d1 execute with --local, then --remote. The post is live on the next request, with no redeploy.
Can a Cloudflare Worker send email over SMTP?
Yes, through connect() from cloudflare:sockets, as long as you authenticate to a mail provider on a port other than 25. Port 465 with secureTransport: 'on' is what this site uses.
Why are the blog posts only in English?
Translating long technical posts well is a lot of work, and machine-translated copies would be weak duplicate pages. The UI strings and main pages are translated. detectLang() forces English on every blog path. The translated main pages are noindex, so no page on the site uses hreflang links.
Do static assets count toward the Workers request limit?
No. Cloudflare serves matching files before the Worker runs, and its pricing page lists static asset requests as free and unlimited. Only requests that reach your code count toward the Free plan's 100,000 per day.
If you're planning a similar build on Workers and D1 and want someone to look over the architecture, get in touch here.