ZHIYONG (JUSTIN) HE GitHub @ZYHE16 Based in EINDHOVEN, NL Now CHECKING
← All writing

Jul 23, 20267 min read11 sections

Building My Personal Website: Plain HTML, a Tiny Node Server, and Directus

A technical tour of how I built my personal website — a no-framework frontend, a tiny Node server, and a private Directus content pipeline on Railway.

In this article 11 sections

This website runs on three server dependencies, zero frameworks, and one small Node process. Its words and photos live in a Directus instance that the public internet never talks to directly. This post is a tour of how the pieces fit — and, fittingly, the first article to travel through the pipeline it describes.

Why build it myself

I could have parked my writing on a platform and been done in an afternoon. I didn't want that. I wanted a corner of the internet that I understand end to end: every byte the browser downloads, every query that produces it, and every boundary that keeps the wrong thing out. Plus, I'm on summer holidays and I'm a bit bored, then why out creating a personal website (I've been wanting to do it for a long long time).

So I gave myself a set of constraints on purpose. No frontend framework. No build step. Server-rendered articles. A real CMS behind the scenes, because editing in a textarea over SSH gets old fast.

The shape of the system

The repository is an npm workspace with two deployable halves. The website is one app; the CMS is another. They share a repository and nothing else.

my-website/
  apps/
    web/                        the deployable website
      public/                   plain HTML, CSS, JavaScript
      src/server.js             node:http server and API routes
      src/directus.js           server-side Directus client
      src/blog-render.js        article SSR: sanitize, TOC, RSS
    directus/                   the CMS, deployed as its own service
      extensions/               a small protected content endpoint
      schema/snapshot.yaml      posts + photos, version controlled

Both sides deploy to Railway from the same repository, but they are separate services with separate domains and separate secrets. The public only ever sees one of them.

Reader HTTPS Website Node, public private network Directus admin + content API SQL / files PostgreSQL + uploads volume
Me, writing separate admin domain Directus admin
Figure 1 — The public never talks to Directus; the website is the only path in.

Plain HTML on purpose

The pages are hand-written HTML with a shared stylesheet. There is no hydration, no virtual DOM, and no client-side routing, because the content simply doesn't need them. View source is readable. Pages cache well. Nothing breaks when JavaScript doesn't load.

Interactivity is layered on with small, page-specific scripts: the terminal on the home page, a live GitHub repository list on the projects page, tag filters on the blog index, and the photo gallery. Each is a plain script tag, so the home page never downloads the gallery's code, and vice versa.

0 frameworks 3 server dependencies 1 node:http server 30s content cache

A tiny server that does more than serve files

The whole backend is one node:http server — no Express, no framework — plus exactly three dependencies: marked for Markdown, highlight.js for code blocks, and sanitize-html for safety. Static files are the boring half of its job. The interesting half is a handful of routes:

RouteWhat it does
/api/healthLiveness check for the web service.
/api/cms-healthA fresh round-trip that proves the private Directus connection works.
/api/contentPublished post metadata and photos. Article bodies stay on the server.
/api/assets/:idProxies CMS images, but only ones referenced by published content.
/blog/:slugServer-rendered article pages, like this one.
/rss.xmlAn RSS feed generated from the same published content.

Two details are worth pointing out. Article bodies never reach the browser as data — /api/content returns metadata only, and the HTML you are reading right now was rendered on the server. And legacy /blog?post=slug URLs redirect to real article URLs, so every post gets one canonical address.

Content lives in Directus

I did some researches and found about about Directus, and it's been surprisingly good, especially with the ability to publish from anywhere without touching git or redeploying anything.

The schema is deliberately tiny and version-controlled. A posts collection — title, unique slug, excerpt, body, tags, optional cover, and a body_format switch between Markdown and HTML — and a photos collection with an image, alt text, caption, and taken date. Both carry the same three-state status: draft, published, archived.

  1. Draft written in Directus, invisible to the site
  2. Published one status change, no redeploy
  3. Live on the blog within about 30 seconds
Figure 2 — Publishing is a status flip, not a deployment.

This post itself is stored as HTML — body_format: html — which is what allows the figures in it. Markdown bodies travel through the exact same pipeline.

The rendering pipeline

When you open an article, this is what happens between the CMS and your screen:

  1. Fetch published posts from the protected endpoint, with a 30-second cache.
  2. Render the body — Markdown through marked, HTML taken as authored.
  3. Anchor every h2 and h3 with a stable ID.
  4. Sanitize against an allowlist; scripts, iframes, and inline styles never survive.
  5. Compose the page: table of contents, reading time, tags, prev/next, related posts.
  6. Serve with canonical and Open Graph metadata.
Figure 3 — What happens between Directus and /blog/<slug>.

A few of these deserve a sentence each. Reading time is estimated at 200 words per minute. The table of contents is built from the headings after they get their IDs. Related posts are scored by shared tags, with recency as the tiebreaker. And the RSS feed is generated from the same in-memory content, so it can never disagree with the index.

The 30-second cache is the entire reason a freshly published post can take half a minute to appear — a trade I happily make to avoid hammering the CMS on every request.

Security boundaries I actually care about

A personal site is a small target, but small is no excuse for sloppy. These are the boundaries the design enforces:

  • The Directus hostname and the content key exist only on the server. Nothing shipped to the browser can reach the CMS.
  • A custom endpoint inside Directus returns only published records and only public fields — it is not the admin API with a filter taped on.
  • Images are proxied through the website, and the allowlist is checked twice: the CMS extension and the website each verify that an asset belongs to published content before serving it.
  • Article HTML is sanitized server-side, so a careless paste into the CMS cannot become an XSS.
  • Draft and archived items are never serialized, so an unfinished post cannot leak through an API response.

Running it on Railway

I've been using railway for a long time, it's 100% my favourite hosting platform so far. Production is three pieces: the website service, the Directus service, and a dedicated PostgreSQL database, plus a persistent volume mounted at /directus/uploads so media survives redeploys. The website reaches Directus over Railway's private network — plain HTTP inside the private perimeter, never exposed — while I use a separate public admin domain to write.

Website service public domain private HTTP Directus service 12.1.1, admin domain SQL PostgreSQL backups on
Directus service uploads path writes files to Volume /directus/uploads
Figure 4 — Three services from one repository; the database and uploads are both backed up.

The CMS image is pinned to Directus 12.1.1, and the schema is reapplied from the repository on every deploy, so the content model is reproducible even though the content itself lives in Postgres.

The terminal on the home page

The home page has a fake terminal, and it is the part of this site visitors ask about most. Under the hood it is a small state machine in a single file: a command parser, a screen buffer, and a renderer. Most commands can be displayed by entering help.

A few commands are hidden on purpose and absent from help. One of them replays a NixOS build failure.

Trade-offs

The honesty section. The in-memory cache means up to 30 seconds of staleness. The server is a single process with no horizontal scaling, which would worry me if this were a business and not a blog.

Use of AI

GPT-5.6 sol helped me a lot while I was building this site, especially with the rendering pipeline and the security side of it. Because I had almost zero clue about either area. I also used it to rephrase several parts in this blog post.

Keep reading

Related notes