Building a Static Portfolio and CMS With Zero Backend
I've deployed a lot of things. Kubernetes clusters, Docker Swarms, managed databases, serverless functions. For my own portfolio, I wanted to do the opposite: deploy nothing.
The result is this site: a portfolio, blog, and content management system that runs entirely in the browser, with GitHub as both the host and the database.
Here's how it works.
The constraints I set for myself
Before writing a single line of code, I fixed the rules:
- No build step. No webpack, vite, or bundler of any kind.
- No framework. Vanilla JS only.
- No backend. No server, no API, no database.
- Single-file pages. Each HTML file owns its own
<style>and<script>. - Three CDN libraries maximum:
marked.jsfor markdown,highlight.jsfor code,DOMPurifyfor sanitisation.
The goal was a site I could understand entirely, deploy for free, and edit from any browser without pulling in dependencies that would rot in six months.
The architecture
The stack is four HTML files, two JSON files, and a folder of markdown:
index.html â portfolio homepage
blog.html â post listing with search + tag filters
post.html â single post reader
admin.html â in-browser CMS
data/config.json â all portfolio content (source of truth)
data/posts-index.json â blog post metadata
posts/*.md â blog posts with YAML front matter
GitHub Pages serves everything as static files. The browser does all the work.
GitHub as a database
The most interesting part of this setup is the CMS. It authenticates with a GitHub Personal Access Token (stored in localStorage, never logged, never sent anywhere except api.github.com) and uses the GitHub Contents API to read, write, and delete files directly in the repository.
Reading a file:
async function readFile(path) {
const res = await fetch(`${API}/repos/${OWNER}/${REPO}/contents/${path}`, {
headers: apiHeaders()
});
if (!res.ok) throw new Error(`Read failed: ${res.status}`);
const { content, sha } = await res.json();
return { content: atob(content.replace(/\n/g, '')), sha };
}
Writing a file:
async function writeFile(path, content, sha, message) {
const body = {
message,
content: btoa(unescape(encodeURIComponent(content))),
...(sha && { sha }),
};
const res = await fetch(`${API}/repos/${OWNER}/${REPO}/contents/${path}`, {
method: 'PUT',
headers: apiHeaders(),
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Write failed: ${res.status}`);
return res.json();
}
Two things worth noting here:
The SHA requirement. The GitHub API requires the current file SHA when updating an existing file. If you skip it, you get a 409 Conflict. The CMS caches SHAs in a shaCache object after every read and write, so subsequent saves don't fail.
Unicode-safe base64. Plain btoa() breaks on non-ASCII characters. The pattern btoa(unescape(encodeURIComponent(content))) handles any unicode correctly.
The CMS
The admin panel has three tabs:
Portfolio editor: collapsible sections for bio, skills (add/remove categories and items), projects (expandable blocks with tech tags, bullets, GitHub/live links), and DevOps entries. Saves to data/config.json via the GitHub API. The homepage reads this file and renders everything dynamically, no content is hardcoded in the HTML.
Blog posts: a table of all posts pulled from data/posts-index.json. Each row has edit and delete actions. Delete shows a confirmation overlay, removes the .md file, and updates the index in the same operation.
Post editor: a split-pane markdown editor with:
- A toolbar for common formatting (Bold, Italic, H2, H3, Link, Code, Code Block, List, Quote, HR)
- Live preview with syntax highlighting, debounced 300ms
- Front matter fields (title, date, excerpt, tags) with auto-slug generation
- Draft autosave to
localStorageevery 30 seconds - An unsaved-changes warning on
beforeunload - Tab key inserts two spaces instead of moving focus
Saving publishes the .md file and updates the index in sequence. If the index write fails after the post write succeeds, the index is stale but the post file is safe; the next save will retry with the correct SHA.
The post reader
post.html fetches the markdown file directly as a static asset, parses the YAML front matter, and renders with marked.js + DOMPurify. Code blocks get highlight.js applied per-element and per-block copy buttons injected on hover.
The reading progress bar is a CSS width animation driven by the scroll position:
window.addEventListener('scroll', () => {
const scrolled = window.scrollY;
const total = document.body.scrollHeight - window.innerHeight;
progressBar.style.width = `${Math.min(100, (scrolled / total) * 100)}%`;
});
The table of contents is built by scanning the rendered HTML for h2 and h3 elements, assigning deterministic IDs, and tracking the active heading with an IntersectionObserver.
One gotcha: Jekyll
GitHub Pages runs Jekyll by default, which intercepts .md files and tries to render them as HTML instead of serving them raw. The post reader fetches posts/{slug}.md directly; if Jekyll is active, that request 404s.
The fix is a single empty file at the repository root:
.nojekyll
That's it. Jekyll disabled. .md files served as-is.
The design system
Every colour, every spacing decision, every font choice is driven by CSS custom properties. No colour is hardcoded anywhere in the HTML files. The palette:
--color-bg: #0B1220
--color-surface: #111827
--color-border: #1E3A5F
--color-accent: #2563EB
--color-gold: #F59E0B
--color-text: #F1F5F9
Fonts are DM Serif Display for headings, DM Sans for body text, and JetBrains Mono for code and slugs, all loaded from Google Fonts with display=swap.
What I'd do differently
Caching. The site uses sessionStorage to cache config.json and posts-index.json on first load. This avoids redundant fetches but means content changes don't propagate until the cache is cleared. A smarter approach would be to version-stamp the cached data and invalidate it after a known write.
Conflict handling. If two browser tabs edit the same file simultaneously, the second write will fail with a 409. The CMS surfaces the error but doesn't auto-resolve it. Good enough for a single-author site.
No markdown in the portfolio editor. The bio field is plain text. Skills, project descriptions, and bullets are all plain strings. This was a deliberate simplification; the complexity of a rich-text or markdown editor in that tab wasn't worth it for fields that rarely change.
The result
A portfolio and blog that loads in under a second, costs nothing to run, requires no deployment pipeline, and can be edited from any browser with a PAT. Every post is a markdown file in a git repository. Every change is a commit.
The whole thing is about 160KB of source code: four HTML files, two JSON files, a handful of markdown posts. No node_modules. No lockfile. No build artefacts.
I think that's the right size for a personal site.