Building This Portfolio with SvelteKit
How I rebuilt my portfolio from scratch — static HTML to a SvelteKit site with a markdown-powered blog, reactive search, and a single global stylesheet.
I've had a plain HTML/CSS portfolio sitting around longer than I care to admit. It worked, but adding anything new meant editing raw HTML, and there was no real blog to write on. So I rebuilt it from scratch with SvelteKit.
This post covers the decisions that shaped the final result.
Why SvelteKit?
I considered the usual suspects — Next.js, Astro, Nuxt — but landed on SvelteKit for a few reasons:
- No virtual DOM overhead. Svelte compiles to vanilla JS. For a personal site, that's the correct tradeoff: tiny bundles, instant paint.
- File-based routing out of the box. No router config. A file at
src/routes/contact/+page.svelteis just/contact. adapter-staticis perfect here. This site has no dynamic server requirements — it's prerendered HTML/CSS/JS, deployable anywhere.
The full site builds in under 5 seconds and ships as static files with no runtime dependency.
Static Prerendering
One line in +layout.js enables full static export:
export const prerender = true;
SvelteKit walks every route at build time and writes .html files. For dynamic routes like /blog/[slug], you export an entries() function that tells the builder which slugs exist:
export function entries() {
return getAllPosts().map((p) => ({ slug: p.slug }));
}
At build time, SvelteKit calls entries(), then calls load() for each slug, and outputs a static HTML file per post. Zero server needed at runtime.
Markdown-Powered Blog
Posts live as .md files in src/lib/posts/. Vite's import.meta.glob pulls them all in at build time:
const rawPosts = import.meta.glob('/src/lib/posts/*.md', {
query: '?raw',
import: 'default',
eager: true
});
From there, gray-matter parses the YAML frontmatter and marked converts the markdown body to HTML:
import matter from 'gray-matter';
import { marked } from 'marked';
const { data, content } = matter(raw);
const html = marked.parse(content);
No databases, no CMS, no API calls. A new post is just a new .md file with a frontmatter block:
---
title: "Post Title"
date: "2026-04-01"
category: tutorials
tags: react typescript
excerpt: "Short description shown on the blog card."
readTime: 5 min read
---
Drop the file in, rebuild, done.
Reactive Search Without a State Library
The blog index has live search and category filtering. In React I'd reach for useState and useEffect. In Svelte, reactive statements ($:) handle this without any imports:
<script>
let search = '';
let activeFilter = 'all';
$: filteredPosts = data.posts.filter((post) => {
const matchFilter = activeFilter === 'all' || post.category === activeFilter;
const query = search.toLowerCase().trim();
return (
matchFilter &&
(!query ||
post.title.toLowerCase().includes(query) ||
post.tags.includes(query))
);
});
</script>
<input bind:value={search} placeholder="Search articles…" />
filteredPosts recomputes automatically whenever search or activeFilter changes. No useEffect, no dependency arrays, no stale closure bugs.
Single Global Stylesheet
I kept one global src/app.css instead of scoped component styles. For a site this size, global CSS with custom properties is simpler to reason about than per-component styles:
:root {
--evergreen: #132a13;
--hunter-green: #31572c;
--fern: #4f772d;
--palm-leaf: #90a955;
--lime-cream: #ecf39e;
}
Every component reads from these variables, so tweaking the palette is a single-file change.
Mobile Nav Without a Library
The hamburger nav closes on outside click using bind:this — a direct reference to the DOM node — rather than a click listener on document:
<script>
let navEl;
function handleWindowClick(e) {
if (navOpen && !navEl.contains(e.target)) navOpen = false;
}
</script>
<svelte:window on:click={handleWindowClick} />
<nav bind:this={navEl}>...</nav>
This avoids the a11y lint warning Svelte raises when you put on:click on a non-interactive element, and it's simpler than a click-outside directive.
What I'd Do Differently
- Add syntax highlighting.
markedrenders code blocks as plain<pre><code>, no token colors. Shiki or Prism would fix that. - RSS feed. Easy with SvelteKit — a
src/routes/rss.xml/+server.jsthat returns the posts as XML. - OG image generation. Auto-generated social preview cards per post would be a nice touch.
Stack
| Layer | Tool |
|---|---|
| Framework | SvelteKit 2 |
| Build | Vite 5 |
| Markdown parsing | gray-matter + marked |
| CSS | Vanilla CSS, custom properties |
| Deployment | Static files (adapter-static) |
None of this is groundbreaking — it's a deliberately boring stack. For a personal site, boring is correct.