Ahnii!

Static sites are fast and cheap to host, but your data goes stale the moment you deploy. This post shows how a SvelteKit portfolio site serves live data from five external sources while still deploying as static HTML to GitHub Pages.

The Setup

The site uses SvelteKit with adapter-static, which prerenders every page to HTML at build time. The output is a directory of .html files deployed to GitHub Pages. No server, no edge functions, no serverless runtime.

// svelte.config.js
import adapter from '@sveltejs/adapter-static';

const config = {
  kit: {
    adapter: adapter({
      fallback: '404.html',
      strict: false
    })
  }
};

The fallback: '404.html' line is the key. GitHub Pages serves this file for any URL that doesn’t match a prerendered page, which lets SvelteKit’s client-side router take over.

Three Tiers of Freshness

Not all data needs to be live. The site uses three strategies depending on how fresh the data needs to be.

Tier 1: Prerendered at Deploy

The homepage fetches articles from the North Cloud API using a +page.server.ts loader. This runs only at build time because the page is prerendered.

// src/routes/+page.server.ts
export async function load({ fetch }) {
  let northCloudArticles = [];
  try {
    northCloudArticles = await fetchNorthCloudFeed(fetch, 'pipeline', 6);
  } catch {
    // Feed optional on homepage
  }
  return { northCloudArticles };
}

The data is baked into the HTML. It updates when you deploy, not when the API changes. This is fine for a homepage showcase where articles from yesterday are still relevant.

Tier 2: Cached Client-Side

The blog page fetches an external RSS feed at runtime in the browser. The service layer caches results for 30 minutes.

// src/lib/services/blog-service.ts
const FEED_URL = 'https://jonesrussell.github.io/blog/feed.xml';
const CACHE_DURATION = 1000 * 60 * 30; // 30 minutes

export const fetchFeed = async (
  fetchFn: typeof fetch,
  { page = 1, pageSize = 5 } = {}
) => {
  const cacheKey = `blog-feed-cache-${page}-${pageSize}`;
  const cached = feedCache.getCache(cacheKey);

  if (cached) {
    return {
      items: cached.data.slice((page - 1) * pageSize, page * pageSize),
      hasMore: cached.data.length > page * pageSize
    };
  }

  const response = await fetchFn(FEED_URL);
  const posts = parseXMLFeed(await response.text());
  feedCache.updateCache(cacheKey, posts);

  return {
    items: posts.slice((page - 1) * pageSize, page * pageSize),
    hasMore: posts.length > page * pageSize
  };
};

The blog page itself has prerender = false, so there’s no static HTML for it. When you navigate to /blog, GitHub Pages serves the SPA fallback, and SvelteKit loads the RSS feed client-side. New blog posts appear within 30 minutes without a redeploy.

Tier 3: Prerendered With Live Refresh

Series pages combine prerendering with live data. They’re statically generated at build time (good for SEO), but on client-side navigation they fetch fresh data from the Hugo JSON endpoint and live source code from GitHub.

// src/routes/blog/series/[id]/+page.ts
export const prerender = true;

export async function entries() {
  const response = await fetch(SERIES_JSON_URL);
  const data = await response.json();
  return data.series.map((s) => ({ id: s.id }));
}

export const load: PageLoad = async ({ params }) => {
  const series = await fetchSeries(globalThis.fetch, params.id);
  // Fetches companion code from raw.githubusercontent.com
  const allEntries = series.groups.flatMap((g) => g.entries);
  const codeResults = await Promise.all(
    allEntries.map((entry) =>
      fetchSeriesCode(globalThis.fetch, repoSlug, entry.companionFiles ?? [])
    )
  );
  return { series, codeResults };
};

The entries() function tells SvelteKit which series IDs exist at build time, so each one gets a prerendered HTML page. The load function runs both at build time (for prerendering) and at runtime (on client-side navigation), so the data is always current when you browse.

The Fetch Injection Pattern

Every service takes fetchFn: typeof fetch as its first parameter instead of using the global fetch directly.

export async function fetchSeries(
  fetchFn: typeof fetch,
  id: string
): Promise<Series | null> {
  const index = await fetchSeriesIndex(fetchFn);
  return index.series.find((s) => s.id === id) ?? null;
}

This matters because SvelteKit provides its own fetch wrapper during SSR and prerendering that handles cookies, relative URLs, and request deduplication. By accepting fetch as a parameter, the same service works during prerendering (with SvelteKit’s fetch) and at runtime (with the browser’s fetch). It also makes testing straightforward since you can pass a mock fetch.

One caveat: during prerendering, SvelteKit’s fetch wrapper can fail on cross-origin requests. For external APIs, use globalThis.fetch in the loader instead.

What Goes Stale and When

Data sourceFreshnessUpdates when
North Cloud feedFrozen at deployNext GitHub Pages deploy
Blog RSS30-min cacheCache expires, page revisited
Series JSONLive on navigationEvery client-side page load
GitHub source codeLive on navigationEvery client-side page load
Markdown resourcesFrozen at deployNext GitHub Pages deploy

The tradeoff is intentional. Homepage data can be a day old. Blog posts need to appear within 30 minutes. Series companion code should always reflect the latest commit.

Avoiding the 404 Flash

If you use prerender = false on a route, GitHub Pages has no HTML file for that URL. It serves a 404 before the SPA fallback kicks in. The page still renders correctly for users, but search engines see the 404 status code, and there’s a brief flash of the fallback page.

The fix for any route with known paths: use prerender = true with an entries() function that returns all valid slugs. This gives you static HTML for the initial load (SEO-friendly, no 404) while still fetching fresh data on client-side navigation.

For routes where the slugs aren’t known at build time (like the blog listing), prerender = false with SPA fallback is the right choice. Just know the SEO tradeoff.

Baamaapii