Design Decisions: Building a Modern Technical Blog
Table of Contents
- 1. Introduction
- 2. Framework Selection
- 3. Content Management: Org-Mode to HTML
- 4. Styling Architecture
- 5. Interactive Features
- 6. SEO Optimization
- 7. Analytics and Tracking
- 8. Newsletter Integration
- 9. Security Considerations
- 10. Accessibility
- 11. Performance Optimizations
- 12. Versioning and Releases
- 13. Conclusion
- 14. References
- 15. tldr
1. Introduction
Building a personal blog might seem like a solved problem in 2026, but I wanted something different. Not just another static site generator output, but an interactive reading experience that reflects how I think about and organize technical content. This post documents the design decisions that shaped chiply.dev, from the choice of frameworks to security considerations.
What started as a simple blog evolved into a platform featuring 3D knowledge graphs, interactive charts, recursive link previews, and a sophisticated content management system built on Emacs org-mode. Along the way, I made dozens of architectural decisions, each with trade-offs worth examining.
2. Framework Selection
2.1. Why SvelteKit?
After evaluating Next.js, Astro, and Hugo, I chose SvelteKit 2 with Svelte 5 for several compelling reasons:
2.1.1. Svelte 5 Runes
Svelte 5 introduced "runes" - a new reactivity system that makes state management explicit and predictable:
// Reactive state declaration let count = $state(0); // Derived values (computed properties) let doubled = $derived(count * 2); // Component props with destructuring let { title, author } = $props();
This approach eliminates the "magic" of Svelte 4's implicit reactivity while remaining concise. Unlike React's useState hooks, runes don't require understanding closures and stale closure bugs.
2.1.2. Compiled Output
Svelte compiles components to vanilla JavaScript at build time, eliminating the runtime overhead of virtual DOM diffing. For a content-heavy blog with interactive visualizations, this results in:
- Smaller bundle sizes (no framework runtime shipped to clients)
- Faster initial page loads
- Better performance on mobile devices
2.1.3. Full-Stack Capabilities
SvelteKit provides file-based routing with integrated API endpoints:
src/routes/
├── +page.svelte # Home page
├── +layout.svelte # Root layout
├── [post]/ # Dynamic blog routes
│ ├── +page.svelte # Post layout
│ └── +page.server.ts # Server-side data loading
└── api/
├── commits/ # GitHub API proxy
├── subscribe/ # Newsletter endpoint
└── preview-proxy/ # Link preview service
This colocation of frontend and backend code simplifies development and deployment.
2.2. Build Tooling: Vite
Vite 7 powers the development experience with:
- Hot Module Replacement (HMR) that updates in milliseconds
- Native ES modules during development (no bundling required)
- Optimized production builds with code splitting
- Built-in TypeScript support
The result is a development server that starts instantly and updates faster than I can switch windows.
2.3. Deployment: Vercel
I chose Vercel for hosting because:
- Native SvelteKit support: The
@sveltejs/adapter-vercelhandles all configuration - Edge functions: API routes run close to users globally
- Automatic previews: Every PR gets a preview deployment
- Analytics integration: Built-in performance monitoring
The vercel.json configuration enables aggressive caching:
{ "headers": [ { "source": "/fonts/(.*)", "headers": [ { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } ] } ] }
3. Content Management: Org-Mode to HTML
3.1. Why Org-Mode?
Rather than using Markdown or a CMS, I write all content in Emacs org-mode. This choice reflects my workflow preferences:
3.1.1. Literate Programming
Org-mode excels at mixing prose with executable code. Code blocks can be evaluated, and their results embedded in the document:
#+BEGIN_SRC python :results output
import pandas as pd
df = pd.read_csv("data.csv")
print(df.describe())
#+END_SRC
For a technical blog, this means code examples are always tested and accurate.
3.1.2. Hierarchical Organization
Org-mode's outline structure maps naturally to blog post sections. I can collapse, rearrange, and navigate large documents efficiently. The heading hierarchy (*, **, ***, etc.) exports cleanly to HTML with proper semantic structure.
3.1.3. Export Flexibility
Org-mode's export system (ox) produces clean HTML with customizable options:
#+OPTIONS: toc:t num:t H:6 html-postamble:nil #+PROPERTY: header-args :eval never-export
These options control table of contents generation, section numbering, heading depth, and code block behavior.
3.2. The Compilation Pipeline
The org-to-HTML pipeline works as follows:
- Authoring: Write content in
org/*.orgfiles - Export: Emacs exports to HTML via
C-c C-e h h - Storage: HTML files live in
src/routes/[post]/ - Loading: SvelteKit loads HTML server-side, extracts metadata
- Rendering: Client-side components parse and enhance the HTML
3.2.1. Metadata Extraction
The server-side loader (+page.server.ts) extracts metadata from HTML:
// Extract title from <title> tag const titleMatch = html.match(/<title>(.*?)<\/title>/); // Extract author from meta tag const authorMatch = html.match(/<meta name="author" content="(.*?)"/); // Extract date from HTML comment const dateMatch = html.match(/<!-- (\d{4}-\d{2}-\d{2})/); // Extract first paragraph as description const descMatch = html.match(/<p[^>]*>([^<]{50,160})/);
This approach keeps all content in org-mode while enabling rich SEO metadata.
3.3. Full-Text Search Indexing
For Algolia search integration, I created Python scripts that chunk content by heading:
# extract_html.py (simplified) def extract_sections(html_content): soup = BeautifulSoup(html_content, 'html.parser') sections = [] for heading in soup.find_all(['h2', 'h3', 'h4', 'h5', 'h6']): section_id = heading.get('id', '') section_title = heading.get_text() content = get_section_content(heading) sections.append({ 'anchor': section_id, 'sectionTitle': section_title, 'content': content[:5000] # Max 5KB per record }) return sections
Each heading becomes a searchable record, enabling jump-to-section from search results.
4. Styling Architecture
4.1. Design Tokens with Open Props
Rather than Tailwind CSS, I chose Open Props - a CSS custom properties library providing design tokens:
@import "open-props/style"; /* Semantic variable mapping */ :root { --bg-primary: #fffefc; /* Warm cream */ --text-primary: var(--gray-8); --link-color: var(--indigo-7); --size-spacing: var(--size-4); /* Consistent spacing */ }
4.1.1. Why Not Tailwind?
Tailwind is excellent for rapid prototyping, but I had specific reasons to avoid it:
- Readability: Long class lists obscure HTML structure
- Semantic naming: I prefer
--text-mutedovertext-gray-400 - Bundle size: Open Props is lighter (~14KB vs Tailwind's JIT)
- Customization: Direct CSS control without fighting abstractions
4.2. Theme System
The blog supports three theme modes: light, dark, and system (follows OS preference).
4.2.1. Implementation Strategy
/* System preference (default) */ @media (prefers-color-scheme: dark) { :root:not(.theme-light) { --bg-primary: var(--gray-9); --text-primary: var(--gray-1); } } /* Manual override classes */ :root.theme-dark { --bg-primary: var(--gray-9); --text-primary: var(--gray-1); } :root.theme-light { --bg-primary: #fffefc; --text-primary: var(--gray-8); }
The CSS cascade ensures manual selection overrides system preference.
4.2.2. Flash Prevention
To prevent a flash of wrong theme on page load, an inline script in app.html runs before rendering:
<script> (function() { const theme = localStorage.getItem('theme'); if (theme === 'dark') { document.documentElement.classList.add('theme-dark'); } else if (theme === 'light') { document.documentElement.classList.add('theme-light'); } })(); </script>
4.3. Typography: Terminus Font
I chose the Terminus monospace font for its:
- Readability: Designed for long coding sessions
- Character distinction: Clear differentiation between similar characters (0/O, 1/l/I)
- Aesthetic: Technical, terminal-inspired appearance matching the blog's theme
Self-hosting with preloading ensures fast font delivery:
<link rel="preload" href="/fonts/TerminusTTF-4.49.3.woff2"
as="font" type="font/woff2" crossorigin>
5. Interactive Features
5.1. 3D Knowledge Graph (DAG3D)
The homepage features a 3D force-directed graph showing relationships between blog posts.
5.1.1. Technology Stack
- 3d-force-graph: High-level library wrapping Three.js and d3-force-3d
- three-spritetext: Renders text labels as 3D sprites
- WebGL: Hardware-accelerated rendering
5.1.2. Performance Optimizations
// Lazy loading with IntersectionObserver const observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting) { initializeGraph(); observer.disconnect(); } }, { rootMargin: '100px' } ); // Pause animation when tab is hidden document.addEventListener('visibilitychange', () => { if (document.hidden) { graph.pauseAnimation(); } else { graph.resumeAnimation(); } });
5.1.3. User Interaction
The graph supports:
- Auto-rotation: Continuous slow rotation for visual interest
- Drag: Stops rotation, allows free camera movement
- Click: Navigates to the clicked post
- Reset: Returns to default view with smooth animation
5.2. Plotly Chart Integration
Interactive charts are defined in JSON and rendered with Plotly.js:
{ "data": [{ "type": "scatter3d", "x": [1, 2, 3], "y": [4, 5, 6], "z": [7, 8, 9], "mode": "markers" }], "layout": { "title": "3D Scatter Plot" } }
5.2.1. Lock/Unlock Mechanism
Charts start "locked" to prevent accidental interaction while scrolling:
- Charts render with
pointer-events: none - An overlay displays "Click to interact"
- Clicking enables the chart (
pointer-events: auto) - Pressing Escape re-locks the chart
5.2.2. Modal Expansion
Charts can expand to fullscreen modals with:
- Full toolbar access
- Animation frame preservation
- Enhanced legend positioning
- 95vw × 90vh dimensions
5.3. DevPulse: Commit Activity Grid
Inspired by GitHub's contribution graph, DevPulse shows my commit activity:
5.3.1. Multi-Scale Timeline
Five different time scales provide different perspectives:
- Days: 7-column grid (weekdays)
- Weeks: 52 columns (one year)
- Months: 12 columns (J-D)
- Quarters: 4 columns (Q1-Q4)
- Years: 10 columns (decade view)
5.3.2. Data Pipeline
// API endpoint fetches from GitHub const response = await fetch( `https://api.github.com/repos/${owner}/${repo}/commits`, { headers: { Authorization: `token ${GITHUB_TOKEN}`, Accept: 'application/vnd.github.v3+json' } } ); // Transform to activity grid const commits = await response.json(); const activityMap = aggregateByTimePeriod(commits, scale);
5.4. Recursive Link Previews
Hovering over links shows preview popups, with support for nested previews up to 10 levels deep.
5.4.1. Architecture
User hovers link → 300ms delay → Fetch preview
↓
Internal link? → Clone article content
↓
External link? → Fetch via proxy
↓
Display in popup with sanitized HTML
5.4.2. Proxy Server
External previews route through /api/preview-proxy which:
- Fetches the external page
- Sanitizes HTML with DOMPurify
- Rewrites relative URLs to absolute
- Hides modals, cookie banners, chat widgets
- Returns safe, displayable content
5.4.3. Performance
- 300ms hover delay prevents accidental triggers
- Grace period keeps popup open when moving between link and popup
- 10-second timeout prevents stuck loading states
- Cross-origin iframe sandboxing for security
5.5. Full-Text Search with Algolia
Search is powered by Algolia with InstantSearch.js:
const searchClient = algoliasearch(APP_ID, API_KEY); instantsearch({ indexName: 'posts', searchClient, searchFunction(helper) { if (helper.state.query) { helper.search(); } } });
5.5.2. Jump to Section
Search results link to specific sections with highlight animation:
.search-highlight-target { animation: search-highlight 2s ease-out; } @keyframes search-highlight { 0% { background-color: rgba(138, 106, 170, 0.3); } 100% { background-color: transparent; } }
6. SEO Optimization
6.2. Structured Data (JSON-LD)
Blog posts include schema.org structured data:
{ "@context": "https://schema.org", "@type": "BlogPosting", "headline": "Design Decisions: Building a Modern Technical Blog", "author": { "@type": "Person", "name": "Charlie Holland" }, "datePublished": "2026-01-21", "mainEntityOfPage": { "@type": "WebPage", "@id": "https://chiply.dev/post-design-decisions" } }
This helps search engines understand content relationships.
6.3. Sitemap and RSS
Both are generated at build time by scanning the src/routes/[post]/ directory:
// sitemap.xml/+server.ts export const GET: RequestHandler = async () => { const posts = await discoverPosts(); const sitemap = `<?xml version="1.0" encoding="UTF-8"?> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> ${posts.map(post => ` <url> <loc>https://chiply.dev/${post.slug}</loc> <lastmod>${post.date}</lastmod> <priority>0.8</priority> </url> `).join('')} </urlset>`; return new Response(sitemap, { headers: { 'Content-Type': 'application/xml' } }); };
6.4. Prerendering
All pages are prerendered at build time:
export const prerender = true; export const entries: EntryGenerator = async () => { const posts = await discoverPosts(); return posts.map(post => ({ post: post.slug })); };
This ensures search engines receive fully-rendered HTML.
7. Analytics and Tracking
7.1. Vercel Analytics
Built-in analytics tracks page views and Web Vitals:
import { inject } from '@vercel/analytics'; inject();
7.2. Speed Insights
Real User Monitoring (RUM) captures Core Web Vitals:
- LCP (Largest Contentful Paint)
- FID (First Input Delay)
- CLS (Cumulative Layout Shift)
7.3. Custom Engagement Tracking
I built custom engagement tracking to understand reading behavior:
// Track scroll depth milestones const milestones = [25, 50, 75, 100]; const handleScroll = debounce(() => { const scrollPercent = (scrollTop / scrollHeight) * 100; milestones.forEach(milestone => { if (scrollPercent >= milestone && !reached[milestone]) { reached[milestone] = true; trackEvent('scroll_milestone', { depth: milestone }); } }); }, 100);
Metrics tracked include:
- Scroll depth (25%, 50%, 75%, 100%)
- Time on page (active time, excluding hidden tabs)
- Read completion (>90% scroll depth)
7.4. Microsoft Clarity
Clarity provides heatmaps and session recordings for UX analysis:
// Secure initialization with validation const clarityId = import.meta.env.VITE_CLARITY_ID; if (clarityId && /^[a-zA-Z0-9]+$/.test(clarityId)) { const script = document.createElement('script'); script.src = `https://www.clarity.ms/tag/${clarityId}`; script.async = true; document.head.appendChild(script); }
9. Security Considerations
9.1. HTML Sanitization
All user-facing HTML is sanitized with DOMPurify:
import DOMPurify from 'dompurify'; const ALLOWED_TAGS = [ 'article', 'section', 'nav', 'header', 'footer', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'ul', 'ol', 'li', 'a', 'strong', 'em', 'pre', 'code', 'blockquote', 'table', 'tr', 'td', 'th', 'img', 'figure', 'figcaption', 'svg', 'path' ]; const ALLOWED_ATTR = [ 'id', 'class', 'href', 'src', 'alt', 'title', 'aria-label', 'aria-hidden', 'role', 'data-*', 'width', 'height' ]; export const sanitizeHtml = (html: string) => { return DOMPurify.sanitize(html, { ALLOWED_TAGS, ALLOWED_ATTR, ALLOWED_URI_REGEXP: /^(?:https?|mailto|tel):/i }); };
9.2. Content Security Policy
The CSP header restricts resource loading:
// hooks.server.ts const csp = [ "default-src 'self'", "script-src 'self' 'unsafe-inline' 'unsafe-eval' cdn.jsdelivr.net cdnjs.cloudflare.com", "style-src 'self' 'unsafe-inline' cdn.jsdelivr.net", "img-src 'self' data: blob: https:", "font-src 'self' data: cdn.jsdelivr.net", "connect-src 'self' api.github.com *.algolia.net", "frame-src 'self'", "object-src 'none'", "base-uri 'self'", "upgrade-insecure-requests" ].join('; '); response.headers.set('Content-Security-Policy', csp);
9.3. Security Headers
Additional headers prevent common attacks:
response.headers.set('X-Frame-Options', 'SAMEORIGIN'); response.headers.set('X-Content-Type-Options', 'nosniff'); response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); response.headers.set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()'); response.headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
9.4. Input Validation
All API inputs are validated:
// Email validation (RFC 5321 compliant) const isValidEmail = (email: string): boolean => { if (typeof email !== 'string') return false; if (email.length > 254) return false; const [local, domain] = email.split('@'); if (!local || !domain) return false; if (local.length > 64) return false; if (/\.\./.test(email)) return false; return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); }; // URL validation const isValidUrl = (url: string): boolean => { try { const parsed = new URL(url); return ['http:', 'https:'].includes(parsed.protocol); } catch { return false; } };
10. Accessibility
10.1. Semantic HTML
Org-mode exports clean semantic HTML:
<article>
<header>
<h1>Post Title</h1>
<time datetime="2026-01-21">January 21, 2026</time>
</header>
<section id="introduction">
<h2>Introduction</h2>
<p>Content...</p>
</section>
</article>
10.2. ARIA Labels
Interactive elements include ARIA attributes:
<button aria-label="Toggle theme" aria-pressed={isDark} onclick={toggleTheme} > <i class="fa-solid fa-moon" aria-hidden="true"></i> </button> <dialog role="dialog" aria-modal="true" aria-labelledby="modal-title" > <h2 id="modal-title">Search</h2> </dialog>
10.4. Focus Management
Modals trap and restore focus:
let previouslyFocusedElement: HTMLElement | null = null; function openModal() { previouslyFocusedElement = document.activeElement as HTMLElement; modalElement.focus(); } function closeModal() { previouslyFocusedElement?.focus(); previouslyFocusedElement = null; }
10.5. Skip Link
A skip link allows keyboard users to bypass navigation:
<a href="#main-content" class="skip-link"> Skip to main content </a>
11. Performance Optimizations
11.1. Lazy Loading
Heavy components load on demand:
// IntersectionObserver for viewport-triggered loading const observer = new IntersectionObserver( (entries) => { entries.forEach(entry => { if (entry.isIntersecting) { loadComponent(); observer.unobserve(entry.target); } }); }, { rootMargin: '100px' } );
11.2. Code Splitting
Dynamic imports split the bundle:
// Only load Algolia when search opens const loadSearch = async () => { const { default: algoliasearch } = await import('algoliasearch'); const { default: instantsearch } = await import('instantsearch.js'); // Initialize search... };
11.3. Caching Strategy
Different resources have different cache lifetimes:
| Resource Type | Browser Cache | CDN Cache |
|---|---|---|
| Fonts | 1 year | 1 year |
| Static assets | 1 year | 1 year |
| API responses | 5 minutes | 1 hour |
| HTML pages | 0 | 1 hour |
11.4. Image Optimization
Images are optimized with:
- WebP format where supported
- Lazy loading via
loading"lazy"= - Appropriate sizing with
srcset - Placeholder aspect ratios to prevent CLS
12. Versioning and Releases
12.1. Semantic Versioning
The project uses SemVer with automated releases:
| Commit Type | Version Bump |
|---|---|
fix: |
PATCH (0.0.x) |
feat: |
MINOR (0.x.0) |
BREAKING CHANGE: |
MAJOR (x.0.0) |
12.2. Release Please
Google's release-please automates version management:
- Conventional commits trigger Release PRs
- PRs include changelog updates
- Merging creates GitHub releases and tags
package.jsonversion updates automatically
12.3. Changelog Generation
Changelogs are auto-generated from commit messages:
## [0.1.0] - 2026-01-21 ### Features - Add share button with social media platforms - Add 3D knowledge graph visualization ### Bug Fixes - Fix theme flash on page load - Correct chart modal focus management
13. Conclusion
Building chiply.dev has been an exercise in thoughtful engineering. Every decision - from Svelte's compiled output to org-mode's literate programming - serves the goal of creating an engaging, performant, and accessible reading experience.
The codebase reflects my belief that personal projects should be laboratories for exploring ideas. The recursive link previews might be over-engineered, but they taught me about iframe security policies. The 3D knowledge graph might be unnecessary, but it forced me to learn WebGL performance optimization.
If you're building your own technical blog, I hope this post provides useful starting points. Feel free to explore the source code on GitHub, and don't hesitate to reach out with questions or suggestions.
14. References
15. tldr
This post details the architectural decisions behind chiply.dev, a modern technical blog built with SvelteKit 2 and Svelte 5's new runes system for explicit reactivity and compiled performance. The content pipeline uses Emacs org-mode for literate programming capabilities, exporting to HTML that gets processed server-side for metadata extraction and chunked into Algolia search records by heading.
The styling leverages Open Props CSS custom properties instead of Tailwind for semantic naming and lighter bundles, with a sophisticated theme system preventing flash-of-wrong-theme on load. The interactive features include a 3D knowledge graph built with Three.js and WebGL, Plotly charts with lock/unlock mechanisms, and the DevPulse commit activity visualization showing GitHub contributions across multiple time scales.
Recursive link previews support 10 levels of nesting, fetching content through a sanitizing proxy server for external sites. The SEO strategy includes comprehensive meta tags, structured data with JSON-LD, and full prerendering at build time. Analytics combine Vercel's built-in monitoring with custom engagement tracking for scroll depth and read completion, plus Microsoft Clarity for heatmaps.
Newsletter subscriptions use Buttondown's API with double opt-in for GDPR compliance. Security measures include DOMPurify HTML sanitization, strict Content Security Policy headers, and comprehensive input validation. The site maintains WCAG compliance through semantic HTML, ARIA labels, and full keyboard navigation support.
Performance optimizations include lazy loading, code splitting with dynamic imports, and aggressive caching strategies differentiated by resource type. The release process uses semantic versioning with Google's release-please automating changelog generation from conventional commits.