Dev.to WebDev 🛠 Dev 👁 0 📖 12 min read

How I Built rusu-ionut.ro: A Personal Site With Zero Hardcoded Pages

Most personal sites are a handful of pages that you edit in code, rebuild and upload. Mine has no pages in the code at all. rusu-ionut.ro is a small content management system that I built for myself: the pages, menus, bl

How I Built rusu-ionut.ro: A Personal Site With Zero Hardcoded Pages

Most personal sites are a handful of pages that you edit in code, rebuild and upload. Mine has no pages in the code at all. rusu-ionut.ro is a small content management system that I built for myself: the pages, menus, blog articles and portfolio projects live in a database, and the site draws whatever the API returns for the address you opened.

This article is the tour I would have wanted before starting: how the site is put together, what is in it, how I keep it from breaking, and the handful of lessons that cost me the most time. The first commit of this rebuild is dated 25 September 2026, and at the time of writing the site is already on version 1.29.

The idea: a site with no hardcoded pages

The rule I started from is simple: a new public URL should be a piece of content, not a code change. If I want a new page I open the backoffice, give it a title, a place in the page tree and a layout, and drop content blocks onto it. No build, no deploy, no pull request.

That rule shaped everything else. Visitors never sign in, because the public side has no user accounts. Only the owner and editors sign in, and only to a separate backoffice. The public site is a read-only reflection of the content.

It is more moving parts than a static site generator, and I will not pretend otherwise. What I get in return is editing from any browser, a real media library, drafts, ordering by drag and drop, and a clean split between content (data) and presentation (components).

The stack at a glance

  • Front end: Vue 3 with TypeScript and Vite, Vuetify 4 for components, Tailwind CSS 4 for layout and utility styling, Pinia for state, Vue Router, Axios for HTTP, TipTap for rich-text editing and ApexCharts for the dashboard.
  • Back end: Node.js with Express 5 and TypeScript, Mongoose for the models, Zod for validating every write, Helmet for secure headers and Multer for uploads.
  • Data: MongoDB.
  • Tooling: ESLint, Prettier, Husky, Vitest and Playwright.

Two apps, one repository. The layout is deliberately boring:

client/    Vue single-page app (public site and backoffice)
server/    Express API: routes, controllers, services, models
scripts/   Repository tooling
e2e/       Playwright specs that drive the real site

On the server the flow is always the same: a router handles the path, a Zod schema validates the body, a thin controller delegates, and a service holds the business logic. On the client, a view only composes components, composables fetch and shape data, and constants hold every label. I come back to why that matters in the section on rules.

How a page is built

A page is a node in a tree with a path, a layout and a list of blocks. Each block sits in one of four regions: banner, main, sidebar or footer. When you open any address that is not an article or a project, a single catch-all view asks the API for the page at that path and hands its blocks to a renderer. The home page is simply the page with an empty path.

The renderer is a lookup table from block type to Vue component. There are eleven types: free rich text, plus ten widgets (fact cards, skills, timeline, logo grid, certifications, profile card, contact form, blog feed, portfolio list and promo banner).

// simplified: one entry per block type
const componentMap = {
  html: HtmlBlock,
  widget_skills: WidgetSkills,
  widget_timeline: WidgetTimeline,
  widget_blog_feed: WidgetBlogFeed,
  // ...
};

Adding a new kind of block means touching exactly two places: the editor's registry, which tells the backoffice which fields to offer, and this renderer. Keeping the two in sync is a rule, so the editor never offers a block the site cannot draw.

Blocks below the fold are not rendered until they are about to scroll into view, and heavier widgets load on demand behind a small loading bar, so the first screen stays light.

Backoffice page editor with the four regions (banner, main, sidebar, footer) and a few blocks placed.
Backoffice page editor with the four regions (banner, main, sidebar, footer) and a few blocks placed.

The add-block picker showing the block types.
The add-block picker showing the block types.

The backoffice

The backoffice is a separate part of the same app, with its own shell and its own light and dark switch, independent of the public theme. A short tour:

  • Dashboard: a chart of how much content there is, a gauge of the average site rating, the latest inquiries, recent sign-ins and a visitors overview.
  • Search: one box that searches blocks, pages, articles, projects and menu links. It shows the text that matched and, for a block, which pages use it.
  • Pages, blocks and menus: a page tree, a reusable block library, and header, footer and social menus that reorder by drag and drop.
  • Media library: uploads with type and size limits, previews, file details and a picker that every editor reuses.
  • Articles and projects: full editors with cover images, categories, tags, publish dates and a draft switch.
  • Audience: an inbox for contact messages, newsletter subscribers, ratings and a log of sign-ins.
  • Settings: owner-only switches, such as taking the site offline for maintenance or showing the ratings widget.

There are two roles. Editors create and update content; owners can also delete things and change settings.

The dashboard with the visitors section visible.
The dashboard with the visitors section visible.

The rich-text editor, and not rewriting your content

Articles and text blocks are written in an editor built on TipTap, with three views: Visual, HTML and Preview. The preview shows the content with the site's own styling, in light or dark.

The detail I am most pleased with is small. A visual editor only understands the markup it was built for; hand it anything else and it quietly drops it on the next edit. So before opening content in Visual mode, the editor checks whether it contains anything it would lose: unsupported tags, attributes or inline styles. If it does, the content opens in HTML mode with a warning, instead of being rewritten behind my back.

The article editor showing the Visual, HTML and Preview switch and the toolbar.
The article editor showing the Visual, HTML and Preview switch and the toolbar.

The blog

The blog you are reading is part of the same system. Articles have a cover image, a category, tags, a publish date and a summary. When I leave the summary empty, the site cuts an excerpt from the first lines of the article for the cards.

The list supports search, category and tag filters, and pagination, and every article shows its reading time. On an article page you can change the text size (it only affects the open article and resets when you leave), copy the link, or open a QR code for the page. The article ends with an author card with ways to get in touch.

Article HTML is cleaned before it is drawn, so even if something odd ends up in a body it cannot run a script on a visitor's page.

An article page on desktop, showing the text-size control and the share panel.
An article page on desktop, showing the text-size control and the share panel.

The portfolio

Projects have their own cards, which you can view as a grid or a list, and their own pages with the story, the technologies and links to the live site and the source. You can search by name or technology and narrow the list with technology chips. The count updates as you filter, and an empty result offers a one-click way back to everything.

A promo banner block can also spotlight one project on any page: a headline, a short pitch, up to two buttons and a screenshot with a gentle float or tilt. A thin light travels around its border. All of that motion switches off for visitors who prefer reduced motion.

The portfolio list with the search field and technology chips.
The portfolio list with the search field and technology chips.

A site that feels alive

Visitors get dark by default, with a light switch. Both themes come from one set of colour tokens shared by Tailwind and Vuetify, so a button, a card and a chart never disagree about what "primary" means.

On the first visit a startup splash holds until the page's content has actually arrived, then fades. You see one welcome instead of a welcome followed by a second wait. The home page hero is an illustrated landscape: drifting clouds and a sun by day; stars, shooting stars and fireflies at night; and a cyclist riding along the crest of the hills. Inner pages have a calmer mountain version. Everything animated respects reduced motion.

The unglamorous states are designed too: a friendly 404, a retry page for when something fails to load, a maintenance page, an unexpected-error screen, and a contact form that guides you through a message and confirms that it was sent. A small feedback invitation and a rating dialog collect opinions, and on phones the invitation starts minimised so it never covers the content.

There is also a standalone résumé page that prints cleanly, and an in-app changelog: click the version number in the footer to see what changed and when.

The same home hero in dark and light theme, side by side.
The same home hero in dark and light theme, side by side.

One page at phone, tablet and desktop width, side by side.
One page at phone, tablet and desktop width, side by side.

Friendly states such as the 404 or maintenance page.
Friendly states such as the 404 or the maintenance page.

Findable and shareable

Each page and article has its own title and description, a canonical address, Open Graph and Twitter metadata for link previews, and structured data. The sitemap is generated from the published content, so it never goes stale. On the page itself, sharing is one click away, with a copy-link control and a QR code for moving from a laptop to a phone.

Visitor statistics without the creepy parts

I wanted to know which pages and countries bring people in, without handing visitors to a third-party tracker. The result is a small, built-in counter with a few firm rules:

  • No analytics cookies, and nothing is written to the visitor's browser.
  • Only the page path is recorded, with query strings and fragments thrown away. Admin screens, previews and obvious bots are excluded, and Do Not Track and Global Privacy Control are honoured.
  • Unique visitors are estimated without storing an IP address or a browser signature, and the records expire automatically after a few months.
  • Countries are worked out locally from a bundled public-domain dataset, so no visitor information is sent to a lookup service.
  • If collecting a visit fails, nothing the visitor sees is affected.

The trade-off is honesty about accuracy. It is an estimate, shared connections and VPNs blur it, and it only counts from the day it was switched on.

Security, in plain words

I will keep this section deliberately general, because how protection works is not something to publish. The principles are ordinary ones, applied everywhere. Every write is validated against a schema before it touches the database. Anything that renders rich content is sanitised first. Roles limit who can delete or change settings, and the API is throttled. Secrets stay in configuration and never in code, and signing in to the backoffice takes more than a password. Errors leave the API as short codes rather than stack traces, and the backoffice turns them into plain sentences.

Rules, tests and a changelog

A project that grows by a feature a day will rot quickly unless something pushes back. In this repository the pushback is a rulebook, AGENTS.md, that binds every contributor, human or AI assistant. It is long, but the ideas are few:

  • One source of truth. Colours come from theme tokens, user-facing text from one dictionary, and option values and icon names from a registry of literals. Writing "flat" or "mdi-plus" inline in a template is a violation.
  • Screens are orchestrators. A new screen only composes smaller components and composables. It never owns the markup of every part.
  • A fixed file order. Template first, then the script with its sections in a set order, then styles only when Tailwind and Vuetify cannot do the job.
  • Leave it cleaner. Every change ends with a cleanup pass: dead styles, unused imports, orphaned logic.
  • Preserve the design. A layout that is already on screen is the design. Fixing one thing must not move another.

Here is what the literals rule looks like in practice:

<!-- before -->
<v-btn color="primary" variant="flat" prepend-icon="mdi-plus">
  Save
</v-btn>

<!-- after: same pixels, every value sourced -->
<v-btn
  :color="UI_COLORS.PRIMARY"
  :variant="UI_OPTIONS.FLAT"
  :prepend-icon="UI_ICONS.PLUS"
>
  {{ LABELS.SAVE }}
</v-btn>

It looks like ceremony until the day a label changes or a button style needs to move, and it is a one-line edit instead of a search through every file.

Tests that look at the real site

Unit tests (Vitest, twenty files today) cover pure logic: text helpers, validation schemas, the visitor-counting rules, the splash screen's timing. The part I care about more is the browser suite: 19 Playwright specs that drive the running site at phone, tablet and desktop widths. The rules for those specs are all there because something went wrong without them:

  • Every user-visible feature ships with a spec that covers all of its states: loading, loaded, failed and empty. The states are produced by holding, failing or faking the network request, not by hoping the database happens to hold the right data.
  • Assert on what is painted, not on its wrapper. A box can have the right size while the image inside it is blank.
  • Wait out the startup splash, because it covers the page while the tests still count the content as visible.
  • Run each spec at least twice. A flaky test is fixed at its cause, never with a retry or a longer timeout.
  • Look at the screenshots. A passing assertion behind an overlay proves nothing.

A Husky hook runs lint, formatting, the unit tests and both builds before a commit is accepted.

A changelog people can read

Every finished feature or fix bumps the version in lockstep across the packages and adds an entry to the in-app changelog. That changelog has its own rule: milestones only, written for visitors, in plain language, never describing how something works or what it runs on. It is the one place where the project talks to its readers, so it avoids the internals on purpose. Version 1.29 is summed up as understand your audience, a project spotlight, a page for every project and a rider on the hills.

One more honest note. The rulebook is written for contributors and AI assistants alike, and I do use assistants on this project. The rulebook is what keeps that work consistent, reviewable and verified, whoever or whatever writes the code.

What I learned along the way

  1. Same names, different layers. Vuetify ships helper classes named like Tailwind's (pt-16, justify-start), and they sit in a CSS layer that outranks Tailwind's responsive variants. A sm: override that looks right in the code silently loses. The fix is to write the value that must win as an arbitrary value, such as pt-[4rem].
  2. Hidden is not clipped. A decorative light travelling around a banner's border was wider than a phone's side margin. Masking it hid the light but did not stop it from widening the page, so the home page slid sideways on phones. Each ring now clips its own light.
  3. A test can pass behind a curtain. Playwright counts the content under the startup splash as visible, so an assertion can pass while the screenshot shows only the splash. Waiting out the splash is now part of every spec.
  4. Start-up cost is user-facing. The first visit after a quiet spell is the one that makes the impression. I trimmed what the server does at boot, so heavy libraries now load on first use, and the splash waits for real content to avoid a second wait.
  5. Constraints are a feature. A rule like "no new local constants for values that already have a home" feels slow for a day, and then it pays back every time a label or a colour has to change.

Where to look next

If you want the short version: a Vue 3 front end and an Express API around a MongoDB database, where pages are data, blocks are components, the backoffice is the only place anything is written, and a rulebook plus a real-browser test suite keeps the whole thing honest.

Click the version number in the footer on rusu-ionut.ro to read the release history, browse the projects to see what else I have built, and if you have a question about any of this, the contact page is a message away.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.