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?
findings
it's possible. and it landed higher than the baseline.
see the numbers
| route | next | sveltekit | Δ |
|---|---|---|---|
| / | 50 | 85 | +35 |
| /deana | 67 | 95 | +28 |
| /sounds | 70 | 96 | +26 |
| /anything-but-analog | 72 | 95 | +23 |
| /shelf | 75 | 90 | +15 |
| /dad | 76 | 90 | +14 |
| /canvas/self | 62 | 70 | +8 |
| /thoughts | 91 | 96 | +5 |
| /benny | 88 | 90 | +2 |
| avg | 72.3 | 89.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).
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 ? "..." : "..."}
/>
);
}<!-- 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.





