Building a Modern SvelteKit Blog: Technical Architecture Decisions
Building a Modern SvelteKit Blog: Technical Architecture Decisions
When I set out to build this blog, I had a clear goal: create a fast, searchable, and aesthetically pleasing platform for sharing thoughts on software engineering. What followed was a series of deliberate technical choices that shaped the final architecture. This post documents those decisions and the reasoning behind them.
The Stack at a Glance
| Layer | Technology |
|---|---|
| Framework | SvelteKit 2.x |
| Runtime | Bun |
| Language | TypeScript |
| Styling | Tailwind CSS 4 |
| UI Components | shadcn-svelte / bits-ui |
| Content | mdsvex |
| Search | Microsoft DocFind |
| Icons | Lucide Svelte |
Decision 1: Bun as the Package Manager and Runtime
The JavaScript ecosystem has no shortage of package managers, but I reached for Bun for several compelling reasons:
Speed
Bun’s install times are significantly faster than npm or yarn. For a project with 50+ dependencies, cold installs complete in seconds rather than minutes. The lockfile (bun.lock) is also more compact than npm’s package-lock.json.
Runtime Consistency
Using bunx --bun ensures that the same runtime environment powers both development and production builds. This eliminates the “works on my machine” class of bugs that often plague Node.js projects.
Native TypeScript
Bun has first-class TypeScript support without requiring ts-node or tsx. This simplifies the toolchain considerably:
{
"scripts": {
"dev": "bunx --bun vite dev",
"build": "bunx --bun vite build",
"check": "bunx --bun svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
}
} Decision 2: Svelte 5 Runes Over Legacy Reactivity
Svelte 5 introduced a new reactivity model based on runes ($state, $derived, $effect). I adopted this immediately because it offers:
- Predictability: No more subtle bugs from implicit reactivity
- Explicitness: State mutations are clearly marked
- Performance: Fine-grained reactivity without virtual DOM overhead
Here’s how runes power the search functionality:
// Before: Implicit reactivity with $:
let query = '';
$: results = search(query);
// After: Explicit with $state and $effect
let query = $state('');
let results = $state<DocfindResult[]>([]);
$effect(() => {
if (!query.trim()) {
results = [];
return;
}
const handle = setTimeout(() => void runSearch(query.trim()), 120);
return () => clearTimeout(handle);
}); The $effect rune provides automatic cleanup—no need to manually manage onDestroy lifecycle hooks.
Decision 3: Tailwind CSS 4 with shadcn-svelte
Tailwind CSS 4 represents a significant shift from v3. I migrated to v4 primarily for:
The New @import Syntax
Gone are the days of tailwind.config.js. Tailwind 4 uses CSS-native configuration:
@import 'tailwindcss';
@import 'tw-animate-css';
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
/* ... */
} This approach feels more natural—styling defined in CSS files rather than JavaScript configuration.
shadcn-svelte Integration
The shadcn-svelte component library provides 40+ accessible, unstyled components. Rather than fighting with a pre-designed component library, I get composable primitives:
<script>
import {
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandLinkItem
} from '$lib/components/ui/command';
</script>
<CommandDialog bind:open>
<CommandInput bind:value={query} />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Search results">
{#each results as result}
<CommandLinkItem href={result.href}>
{result.title}
</CommandLinkItem>
{/each}
</CommandGroup>
</CommandList>
</CommandDialog> Decision 4: mdsvex for Content Management
For blog content, I chose mdsvex—a preprocessor that allows Svelte components inside Markdown files. This provides the best of both worlds:
- Authoring Experience: Write in Markdown with frontmatter
- Component Power: Embed interactive Svelte components when needed
- Type Safety: Full TypeScript support for content metadata
Content files use the .svx extension:
---
title: 'Hello World'
date: '2026-02-08'
tags: ['Blog']
---
Hi so this is my new blog The +page.ts loader uses import.meta.glob to discover and load all .svx files dynamically:
export const load = (async ({ params }) => {
const modules = import.meta.glob('/src/lib/content/*.svx') as ContentModules;
const contentModule = modules[slugToPath(params.slug)];
if (!contentModule) {
error(404, "Can't find content");
}
const { default: component, metadata } = await contentModule();
return { component, metadata };
}) satisfies PageLoad; Decision 5: Custom Search with Microsoft DocFind
Search is a critical feature for any blog. Rather than relying on external services like Algolia, I built a custom solution using Microsoft DocFind.
Why DocFind?
- Offline-first: Search index is generated at build time
- Fast: WebAssembly-powered search with sub-10ms response times
- Privacy: No third-party requests or tracking
- Full-text: Searches content, not just titles
The Search Architecture
The system has three layers:
- Index Generation: A Node.js script (
scripts/build-docfind.mjs) parses all.svxfiles, extracts frontmatter and body content, and generates a search index - Search Worker: DocFind compiles to a WebAssembly module that runs in the browser
- UI Layer: Svelte components provide command palette and dedicated search page
// lib/search/docfind.ts
export const loadDocfind = async () => {
if (!searchPromise) {
const docfindUrl = new URL('/docfind/docfind.js', window.location.origin).toString();
searchPromise = import(/* @vite-ignore */ docfindUrl).then(
(mod) => mod.default as DocfindSearch
);
}
return searchPromise;
}; Fallback Strategy
If DocFind fails to load, the search gracefully degrades to a client-side text search:
const localSearch = (value: string) => {
const cleaned = value.trim().toLowerCase();
const tokens = cleaned.split(/s+/).filter(Boolean);
return localDocs.filter((doc) => {
const haystack = [
doc.title,
doc.excerpt,
doc.body,
doc.tags?.join(' ')
].filter(Boolean).join(' ').toLowerCase();
return tokens.every((token) => haystack.includes(token));
});
}; This ensures search always works, even without the optimized index.
Decision 6: Prerendering for Performance
SvelteKit’s prerender option generates static HTML at build time:
export const prerender = 'auto'; Combined with adapter-auto, this produces a static site that can be deployed anywhere—Vercel, Netlify, GitHub Pages, or a traditional web server.
The benefits are substantial:
- Speed: HTML is pre-rendered; no server-side processing on each request
- SEO: Search engines receive fully rendered content
- Reliability: No runtime dependencies or database connections to fail
Decision 7: Custom SEO Component
Rather than duplicating meta tags across every page, I built a reusable <SEO> component:
<SEO
title={metadata.title}
description={metadata.description || metadata.excerpt}
ogType="article"
publishDate={metadata.date}
modifiedDate={metadata.updated}
author={metadata.author}
tags={metadata.tags}
/> This component handles:
- Standard meta tags (title, description, robots)
- Open Graph tags for social sharing
- Twitter Card metadata
- JSON-LD structured data for rich search results
- Canonical URLs to prevent duplicate content penalties
Decision 8: TypeScript Everywhere
Type safety isn’t optional—it’s foundational. The entire codebase uses strict TypeScript with comprehensive type definitions:
// lib/types.ts
export interface ContentMetadata {
title: string;
date?: string;
author?: string;
description?: string;
excerpt?: string;
tags?: string[];
category?: string;
draft?: boolean;
[key: string]: unknown;
}
export interface ContentModule {
default: Component;
metadata: ContentMetadata;
} This catches errors at compile time and provides excellent IDE autocomplete.
Decision 9: Nuqs for URL State Management
The search page uses nuqs-svelte to synchronize the search query with the URL:
const queryState = useQueryState('q', { defaultValue: '' }); This enables:
- Shareable URLs:
/search?q=sveltelinks directly to results - Browser History: Back/forward navigation works as expected
- SSR Compatibility: Query parameters are available during server-side rendering
Decision 10: Custom Typography and Theming
The visual identity centers on three Google Fonts:
- Josefin Sans: Body text—geometric and modern
- Josefin Slab: Headings—adds warmth and character
- Google Sans Code: Monospace for code blocks
The theme uses OKLCH color space for perceptually uniform dark/light modes:
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.147 0.004 49.25);
/* Stone base color palette */
}
.dark {
--background: oklch(0.147 0.004 49.25);
--foreground: oklch(0.985 0.001 106.423);
} OKLCH ensures that colors maintain consistent perceived brightness across light and dark modes.
Final Thoughts
Building this blog was an exercise in intentional minimalism. Every dependency was evaluated against these criteria:
- Does it solve a real problem?
- Is there a lighter alternative?
- Does it integrate well with the existing stack?
- Will it still be maintained in 2 years?
The result is a codebase that’s fast to develop in, produces optimized builds, and provides an excellent reader experience. The search feels instant. The dark mode transition is smooth. The typography is crisp.
Most importantly, the architecture is boring—in the best possible way. There are no clever hacks or experimental features that will break in six months. Just solid, proven technologies working together harmoniously.
If you’re building a similar project, I encourage you to question every default. Do you really need a CMS? Probably not—Markdown files in Git work beautifully. Do you need a database? Static JSON files generated at build time are faster and simpler. Do you need a complex search backend? A WebAssembly search index loaded on demand provides excellent results with zero operational overhead.
The modern web stack has never been more capable. Use that capability wisely.