Design Decisions: Building a Modern Technical Blog

Design Decisions: Building a Modern Technical Blog

Table of Contents

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:

  1. Native SvelteKit support: The @sveltejs/adapter-vercel handles all configuration
  2. Edge functions: API routes run close to users globally
  3. Automatic previews: Every PR gets a preview deployment
  4. 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:

  1. Authoring: Write content in org/*.org files
  2. Export: Emacs exports to HTML via C-c C-e h h
  3. Storage: HTML files live in src/routes/[post]/
  4. Loading: SvelteKit loads HTML server-side, extracts metadata
  5. 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:

  1. Readability: Long class lists obscure HTML structure
  2. Semantic naming: I prefer --text-muted over text-gray-400
  3. Bundle size: Open Props is lighter (~14KB vs Tailwind's JIT)
  4. 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:

  1. Charts render with pointer-events: none
  2. An overlay displays "Click to interact"
  3. Clicking enables the chart (pointer-events: auto)
  4. Pressing Escape re-locks the chart

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.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.1. Keyboard Navigation

  • Cmd/Ctrl + K: Open search modal
  • Escape: Close search
  • Enter: Navigate to first result
  • Arrow keys: Navigate results

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.1. Meta Tags

Every page includes comprehensive meta tags:

<!-- Basic -->
<meta name="description" content="{description}">
<link rel="canonical" href="{url}">

<!-- Open Graph -->
<meta property="og:title" content="{title}">
<meta property="og:description" content="{description}">
<meta property="og:type" content="article">
<meta property="og:url" content="{url}">
<meta property="og:image" content="{image}">

<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{title}">
<meta name="twitter:description" content="{description}">

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);
}

8. Newsletter Integration

8.1. Buttondown API

Newsletter subscriptions use Buttondown's API:

// /api/subscribe/+server.ts
export const POST: RequestHandler = async ({ request }) => {
  const { email } = await request.json();

  // RFC 5321 validation
  if (!isValidEmail(email)) {
    return json({ error: 'Invalid email' }, { status: 400 });
  }

  const response = await fetch('https://api.buttondown.com/v1/subscribers', {
    method: 'POST',
    headers: {
      Authorization: `Token ${BUTTONDOWN_API_KEY}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({ email })
  });

  return json({ success: true });
};

8.2. Double Opt-In

Buttondown handles confirmation emails, ensuring GDPR compliance and reducing spam.

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.3. Keyboard Navigation

All functionality is keyboard accessible:

  • Tab: Navigate between interactive elements
  • Enter/Space: Activate buttons
  • Escape: Close modals
  • Cmd/Ctrl+K: Open search
  • Arrow keys: Navigate search results

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;
}

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:

  1. Conventional commits trigger Release PRs
  2. PRs include changelog updates
  3. Merging creates GitHub releases and tags
  4. package.json version 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.

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.