— log / 2026-05-18

garden party

my wife offered me tea. i picked 'garden party.' that's how i ended up porting threesam.com from Next.js to SvelteKit — same routes, same design, same everything. a 1:1 swap. here's what happened.

avg perf gain
+17.3
routes ≥ 90
8 / 9
code deleted
5,557 loc
api spend
~$30

why

threesam.com started as a throwaway. a portfolio AI was going to make for me — ramble at the model, stash what came back, ship it. low effort by design. and the original build showed it: Next.js + React because that's what the model defaulted to, canvas sketches stuffed into useEffect cleanups, a dead audio system, deprecated routes nobody hit. vibe-coded.

but i kept opening it. and the more time i actually spent — shaping what i wanted, reading diffs, deleting what wasn't pulling weight — the better it got. garbage in, garbage out. the inverse holds too: care in, care out. the law runs both directions.

at some point the framework itself started showing as friction — React was the inherited default i'd never re-examined. SvelteKit had been in the eventually pile for that exact reason; the cost of switching always felt bigger than the benefit of fixing. with LLM tooling where it is now, that assumption felt worth testing. so we tested it.

purpose

two questions. is a 1:1 port across frameworks even possible? not "mostly done" — a complete transfer, same routes, same sketches, same visual output, zero regressions. and if it's possible, how good can it get? visual parity? performance parity? better?

we wanted a real measurement against a live production baseline. not a toy project. not a greenfield rewrite.

threesam.com made it a good test. canvas-heavy and imperative by nature: 31 generative sketches, WebGL cloud shaders, voronoi images, metaball simulations, particle-text effects, a three.js scene. things that touch the DOM frame-by-frame. React's value is diffing a tree and batching updates. canvas and WebGL bypass the DOM entirely. the component tree was already mostly useEffect hooks pretending to be declarative — React's mental model fighting the actual code shape. which made it a clean test case: does the better-fit framework actually show up in the numbers?

threesam.com home page — clouds background with gallery strip below
/ — clouds + the gallery strip. virtualized canvas tiles.
threesam.com /self page — voronoi-treated portrait and essay
/self — voronoi-treated portrait, essay underneath.
threesam.com /anything-but-analog page — particle text hero and sketch index
/anything-but-analog — particle text hero, 31 sketches behind it.
threesam.com /deana page — D-ANA hero with message timeline
/deana — D-ANA hero, message timeline, word cloud.
threesam.com /shelf page — book grid from Goodreads
/shelf — book grid via Goodreads.
threesam.com /benny page — 102 Jackson Street tribute with video hero
/benny — 102 jackson street tribute, podcast video, playlists.

findings

it's possible. and it landed higher than the baseline.

lighthouse perf, per route — next.js → sveltekit
0255075100/+35/deana+28/sounds+26/anything-but-analog+23/shelf+15/dad+14/canvas/self+8/thoughts+5/benny+2
next.js sveltekit
see the numbers
routenextsveltekitΔ
/5085+35
/deana6795+28
/sounds7096+26
/anything-but-analog7295+23
/shelf7590+15
/dad7690+14
/canvas/self6270+8
/thoughts9196+5
/benny8890+2
avg72.389.7+17.3

next.js live-production baseline vs sveltekit with perf work applied. a11y / best practices / seo: 100 / 99.6 / 100 — held across both.

net

  • perf: avg +17.3 pts. best gain +35 on /. 8 of 9 routes now at 90+.
  • /canvas/self is the outlier at 70 — that's a WebGL-canvas ceiling, not a framework issue.
  • a11y / best practices / seo: 100 / 99.6 / 100 — held everywhere, both before and after.
  • /canvas/self payload: 6.3 MB → ~1.5 MB (album-art WebP conversion).
  • /deana initial JS: 220 KB → 5.5 KB (−97.5%; canvas + message components dynamic-imported).
  • /shelf TTFB: 2.6s → ~0 (prerendered; Goodreads RSS fetch moved to build time).
Next.js — React component
function CloudCanvas({ mirror }) {
  const ref = useRef(null);
  useEffect(() => {
    const canvas = ref.current;
    const gl = canvas.getContext("webgl");
    const cleanup = setupShader(gl);
    let raf = requestAnimationFrame(loop);
    function loop() {
      /* draw */
      raf = requestAnimationFrame(loop);
    }
    return () => {
      cancelAnimationFrame(raf);
      cleanup();
    };
  }, []);
  return (
    <canvas
      ref={ref}
      className={mirror ? "..." : "..."}
    />
  );
}
SvelteKit — action
<!-- CloudCanvas.svelte -->
<canvas use:cloudShader={{ mirror }} />

// cloud-shader.ts
export function cloudShader(node, params) {
  const gl = node.getContext("webgl");
  const cleanup = setupShader(gl);
  let raf = requestAnimationFrame(loop);
  function loop() {
    /* draw */
    raf = requestAnimationFrame(loop);
  }
  return {
    destroy() {
      cancelAnimationFrame(raf);
      cleanup();
    },
  };
}

the port was a pruning pass too. one commit deleted 5,557 lines: a dead audio system, orphan hero canvas components, deprecated case-study route stubs, duplicate lib files shadowing each other between the Next.js root and the SvelteKit src/ tree.

the component shape got simpler. canvas logic that had been jammed into useEffect cleanup cycles — stale refs, double-mounts in strict mode, cleanup ordering — moved into Svelte actions. an action is: here's a node, do something to it, here's how to undo it. that's the exact shape the sketches were already written in. the friction disappeared.

Svelte 5 runes made reactivity explicit. $state, $derived, $effect — you read a component and you know exactly what's reactive and why. no implicit dependency arrays to audit.

we could have shipped the port faster. lift-and-shift, leave the dead code alone, move on. instead we paused to prune — and that pass took days, not hours. the agent reads every file during a port anyway, so orphan code surfaces naturally. the cost of noticing it is essentially zero. the cost is deciding to stop and remove it. worth making. the audio-reactive subsystem was setting state nothing read; the /api/counters endpoint had zero callers; the deprecated case-study routes weren't linked from anywhere. none of it was doing anything — it just looked like it might be.

every change since has been faster. less surface area. prototyping a new section is hours, not a day. tinkering with an interaction doesn't start with "wait, does this audio system still matter?" a smaller codebase is a faster codebase to think in. for a long-lived personal site, the time spent pruning during a re-platform pays back the next time you sit down to build something. when we do this for a client, the cleanup pass isn't scope creep — it's the part that makes the next year of changes cheap.

this port was driven by an AI agent — 78 commits, multi-day execution, granular and reviewable. total LLM cost was roughly tens of dollars in API tokens. closer to a single consulting hour than a sprint. the loop: spec → plan → fresh agent per task → two-stage review → visual regression against prod → lighthouse re-measure. the 1:1 visual fidelity was verified by automated screenshot diffs against the live production site, route by route.

that changes the math on framework choice. the old argument for staying on Next.js + React — even when the fit was bad — was cost. re-platforming meant weeks of refactor work, regression risk, team retraining. studios and teams reached for the defaults and stayed there. when that work compresses to a few days and a small API bill, the constraint melts. framework choice becomes a tactical bet with measurable payback — not a multi-year commitment you're locked into.

this isn't a svelte-beats-react take. the point is: you can read what an app actually does, pick accordingly, and know whether it paid off. sometimes that answer is Next.js. here it wasn't.

live at threesam.com. if your stack has been sitting in the eventually pile — let's talk.

the work is public: #29 (port), #30 (perf), #31 (content-prerender hotfix), #33 (route rename).