Design details and shipping on free tiers
This is the last part of a series on how this blog is built. The earlier parts were about how it works. This one is about how it looks, where it runs, and what is still unfinished.
Three colours
The whole site is black text on white, with one accent: a bright green, the colour of a highlighter pen. There is a darker green for small text such as dates, because the bright one is unreadable on white at small sizes.
:root {
--brand: #087a3c;
--brand-strong: #0a0b0a;
--brand-muted: #555a57;
--brand-soft: #f4f5f4;
--brand-line: rgba(10, 11, 10, 0.12);
--highlight: #2fe072;
}I kept the palette this small on purpose. With one accent colour, anything green on the page means something: it is a link, a date, or something I chose to mark.
The highlighter stroke
The idea that ties the design together is that green is used the way a real highlighter is used: as a stroke across text. The headings on the home page have it. So does selected text anywhere on the site.
The stroke is a background image, not an underline or a border:
.marker,
.marker-sweep {
background-image: linear-gradient(var(--highlight), var(--highlight));
background-repeat: no-repeat;
background-position: 0 86%;
background-size: 100% 0.34em;
-webkit-box-decoration-break: clone;
box-decoration-break: clone;
}A gradient from one colour to the same colour is just a solid block, and a background can be sized and positioned freely. This one is a third of a line tall and sits near the bottom of the text, so it overlaps the lower part of the letters the way a pen stroke would. box-decoration-break: clone makes a heading that wraps onto two lines get a stroke on each line.
Because the size is a plain CSS property, it can be animated. Post titles in lists start with a stroke of zero width, and it sweeps across when you hover:
.marker-sweep {
background-size: 0% 0.34em;
transition: background-size 0.45s cubic-bezier(0.16, 1, 0.3, 1);
}
a:hover .marker-sweep,
a:focus-visible .marker-sweep {
background-size: 100% 0.34em;
}
@media (prefers-reduced-motion: reduce) {
.marker-sweep { transition: none; }
}The last rule matters. Some people ask their system to reduce motion, and for them the stroke appears at once instead of sweeping.
[Screenshot to add: A post title on the blog list, mid-hover, with the green stroke sweeping across]
A cover for posts that have none
Posts can have a cover image, and I will not always have one. The first placeholder was a large grey initial in a grey box, which looked like something had failed to load.
The replacement is a small drawing of a page of writing: the first letter of the title as a drop cap, a few lines of text beside it, and one line run over in highlighter. It is an SVG generated from the post itself. The line lengths and the highlighted line come from a hash of the post's address:
function hash(value: string) {
let h = 2166136261;
for (let i = 0; i < value.length; i++) {
h = Math.imul(h ^ value.charCodeAt(i), 16777619);
}
return h >>> 0;
}
export function CoverFallback({ seed, title }: { seed: string; title: string }) {
const h = hash(seed);
// Never the last row: it is the short closing line of the "paragraph".
const marked = h % (ROWS - 1);
// ...
}So every post without a cover gets its own drawing, and it is the same drawing every time the page renders. Nothing is stored and nothing is random.
[Screenshot to add: Two or three posts without covers side by side, showing the different placeholder drawings]
The tab icon
For a while the browser tab showed the default icon that comes with a new Next.js project. The replacement is the first letter of my name in highlighter green on black, and it is generated by code, not drawn in an image editor:
export const size = { width: 32, height: 32 };
export const contentType = "image/png";
// Browser tab icon: the author's initial in highlighter green on the site black.
export default function Icon() {
return new ImageResponse(
<div style={{ /* a centred letter on a black square */ }}>
{siteConfig.author.charAt(0).toUpperCase()}
</div>,
{ ...size },
);
}Next.js turns a file called icon.tsx into a real PNG at build time. The letter comes from the site's author setting, so it is not hard-coded anywhere.
Running on free tiers
The site runs on Vercel, the database on Neon and the images on Cloudflare R2, all on free plans. Each one changed something in the code.
Image optimisation is switched off
Next.js can resize and compress images on demand, and on Vercel that is a metered service. The free plan includes a limited number of transformations each month, and once they are used up, new ones fail with an error instead of an image. A blog whose images might stop loading at the end of a busy month is not acceptable, so the feature is off:
images: {
// Images live on Cloudflare R2 (free egress, served by Cloudflare's CDN).
// Vercel Hobby only includes 5K image transformations per month and new
// optimizations fail with a 402 once that is used up, so R2 images are
// served as uploaded. Resize/compress before upload (the editor does not).
unoptimized: true,
},The cost is that images are served exactly as uploaded, so I have to resize them myself before uploading.
The database connection is pooled
A serverless app can open far more database connections than a small Postgres allows, so Neon puts a pooler in front of the database. That pooler does not support named prepared statements, which the Postgres driver uses by default. Queries fail in ways that are hard to connect to the cause until you know this. The client is configured around it:
postgres(url, {
// PgBouncer in transaction mode does not support named prepared statements.
prepare: false,
// Serverless: PgBouncer does the real pooling, so keep each instance small.
max: process.env.NODE_ENV === "production" ? 3 : 10,
// Close idle sockets quickly so instances do not hold pooler slots.
idle_timeout: 20,
// Allows for a Neon compute waking up from auto-suspend.
connect_timeout: 15,
ssl: sslFor(url),
});The last two comments point at the other free-tier behaviour: Neon puts an idle database to sleep. The first request after a quiet period waits a second or two while it wakes up. Because public pages are cached, most readers never trigger this; it mostly affects me, opening the admin in the morning.
Migrations are run by hand
Schema changes cannot go through the pooler either; they need a direct connection. I run them from my own machine before deploying code that depends on them. The alternative is to run them as part of every deploy, but then a failed migration blocks the deploy, and two deploys at once can race each other. For one person, a manual step is the smaller risk.
CI builds without a database
Every push runs lint, a type check and a full build in GitHub Actions, with dummy values for every secret. The build never contacts a database; pages that would query one fall back to empty data during the build and fill in on their first real request. Part 4 shows how.
What is still missing
I would rather list these than pretend the project is finished.
No automated tests. The safety net is the linter, the type checker and the build. For the scheduling logic in particular, that is not enough.
No backups. The free database plan has limited restore options. An occasional dump to a file is on my list.
The settings page is a placeholder. The site name and description come from environment variables, which means a redeploy to change them.
Failed uploads can leave records behind. Part 5 explains why.
Images are not resized. With optimisation off, a large upload is a large download for every reader.
Looking back
The thing I would repeat is the decision from part 1 that shaped everything else: store the document as data, and share one schema between the editor and the page. Most of the features in this series were easy because of it. A caption on an image, a green stroke on a heading, a scheduled update to a live post: each one was a small addition in one place, and it worked on both sides.
The thing I underestimated was how much of the work is in details nobody will notice when they are right: a menu that closes when it should, a selection that survives a button press, a status that is never behind the clock. Those are also the parts of Medium I admired without being able to say why, until I had to build them myself.
That is the whole series. If you read all six parts, thank you.
The whole series
A one-person admin: Google sign-in, a sign-in log and direct uploads
Design details and shipping on free tiers (this post)