Skip to content

Pre-rendered language switcher demo , no Suspense, no skeleton, no flash. Syncs with ?lang= query params.

Notifications You must be signed in to change notification settings

icyJoseph/language-switcher

Repository files navigation

Language Switcher Demo

A demo showing how to pre-render multiple code snippets and switch between them via ?lang= query params — no skeleton loaders, no flash. Uses a tiny invisible Suspense boundary for URL sync, but all content is fully pre-rendered.

Inspired by A Clock That Doesn't Snap by Ethan Niser.

The Technique

  1. Server renders all code blocks (ts, js, python, rust) using Shiki with "use cache"
  2. CSS hides all except the default (TypeScript) via parent data-lang attribute
  3. Inline script runs before React hydrates — reads ?lang= from URL, sets data-lang on container
  4. CSS instantly shows the correct panel — no flash, no loading state
  5. React hydrates — useState reads the same URL, matches the DOM, no hydration mismatch
  6. LangSyncer watches URL — any navigation to ?lang=X updates all [data-lang-sync] elements

Why Not Just Use a Skeleton?

A valid and common approach is to render a static shell with a skeleton loader while fetching/computing the dynamic content. This works well when:

  • Content has predictable dimensions
  • A brief loading state is acceptable
  • You want simpler architecture

However, skeletons struggle when content height varies significantly — like code snippets across languages. Our Fibonacci example:

  • TypeScript: ~15 lines
  • Python: ~10 lines
  • Rust: ~20 lines

A fixed-height skeleton would either:

  • Cut off longer snippets (bad)
  • Leave empty space for shorter ones (jarring)
  • Cause layout shift when real content loads (worst)

By pre-rendering all variants and using CSS to switch, we get:

  • Zero layout shift
  • Instant switching
  • Correct height from first paint

Trade-offs: React Elements vs HTML String

Current Approach: HAST → React Elements

We use codeToHast + hast-util-to-jsx-runtime to convert Shiki's output to proper React elements:

const hast = await codeToHast(code, { lang, theme });
const element = toJsxRuntime(hast, { jsx, jsxs, Fragment });

Pros:

  • No dangerouslySetInnerHTML
  • Safe by default — no XSS risk
  • Proper React tree — works with React DevTools, etc.

Cons:

  • More DOM nodes (React adds some overhead)
  • Slightly larger payload

Alternative: HTML String (simpler but requires sanitization)

const html = await codeToHtml(code, { lang, theme });
// ...
<div dangerouslySetInnerHTML={{ __html: html }} />

Pros:

  • Simpler code
  • Smaller payload
  • Fewer DOM nodes

Cons:

  • Must sanitize if code comes from user input — never trust user input
  • Bypasses React's DOM management

For trusted content (like static code snippets), the HTML approach is fine. For user-generated content, use the React elements approach or sanitize with a library like DOMPurify.

Multiple Code Blocks

Wrap multiple <CodeSwitcher /> components in a <LangProvider> — they all sync to the same ?lang= param:

<LangProvider>
  <CodeSwitcher />  {/* Fibonacci */}
  <CodeSwitcher />  {/* QuickSort */}
  <CodeSwitcher />  {/* Binary Search */}
</LangProvider>

Any element with data-lang-sync attribute will be synced when the URL changes.

Reacting to URL Changes

The LangProvider includes an invisible LangSyncer component wrapped in Suspense:

<Suspense fallback={null}>
  <LangSyncer />  {/* uses useSearchParams, syncs DOM */}
</Suspense>

This means:

  • Any link that navigates to ?lang=X will update all code blocks
  • No need to wire up custom click handlers
  • The Suspense boundary is invisible (fallback={null}) — no skeleton

Cache Components

This demo uses Next.js Cache Components (cacheComponents: true) with the "use cache" directive:

async function highlightAll() {
  "use cache";
  // expensive Shiki highlighting happens here
}

The highlighted code is cached and revalidated every 15 minutes, avoiding redundant work on each request.

Running Locally

pnpm install
pnpm dev

Then visit:

About

Pre-rendered language switcher demo , no Suspense, no skeleton, no flash. Syncs with ?lang= query params.

Topics

Resources

Stars

Watchers

Forks