This didn’t start as a migration plan. It started with “what if the whole homepage was a 3D world?” and ended at 2am fighting a WASM binary that npm refused to install.
The Before: Vue + Tres.js
The blogfolio was born on Astro 5 with Vue 3 and Tres.js — a Three.js wrapper for Vue that let you declaratively build 3D scenes in templates. It worked. It looked cool. But it had some issues.
The main Scene.vue was 290 lines of shallowRef soup:
<script setup lang="ts">
import { shallowRef } from "vue";
import { TresCanvas, useRenderLoop } from "@tresjs/core";
import { OrbitControls, Stars, Octahedron } from "@tresjs/cientos";
const yRotation = shallowRef(0);
const greenRotation = shallowRef(0);
const purpleRotation = shallowRef(0);
const greenScale = shallowRef(0.5);
const greenTargetScale = shallowRef(1);
const greenRotationSpeed = shallowRef(0.01);
// ...you get the idea
Every piece of reactive state was a shallowRef. Every animation was a manual lerp in useRenderLoop. GLTF models loaded with useGLTF from @tresjs/cientos, animations played with useAnimations, and hover effects were managed through useInterpolation — a custom composable that duplicated Vue’s reactivity for smooth number transitions.
It worked, but it felt like fighting the framework. Vue’s shallowRef pattern for 3D state is verbose. Every model needed its own set of refs, its own hover handlers, its own scale interpolation. The HolographicLink.vue component alone was 200+ lines of conditionals for loading different GLB shapes based on props.
And then there was the orb system — two octahedrons orbiting the scene, each with their own pointer-over/pointer-out/click handlers that changed precipitation colors, light visibility, and rotation speeds through setTimeout chains. Functional? Yes. Elegant? Not even close.
The Migration: Svelte 5 + Threlte 8
The stack change was straightforward in theory:
| Before (Tres) | After (Threlte) |
|---|---|
Vue 3 + @tresjs/core |
Svelte 5 + @threlte/core |
@tresjs/cientos |
@threlte/extras |
shallowRef + useRenderLoop |
$state + $effect + useTask |
useGLTF from cientos |
useGltf from extras |
useAnimations |
useGltfAnimations |
defineProps |
$props() |
on:click |
onclick |
@tresjs/leches (tweakpane) |
removed |
| Tailwind CSS | removed |
| Astro 5 | Astro 7 |
GLB Models → Svelte Components
The biggest structural change was migrating raw GLB file references into proper Svelte components using @threlte/gltf’s CLI:
npx @threlte/gltf@3.1.0 public/models/Rock.glb --output src/components/models/Rock.svelte --types
This auto-generates a typed Svelte component that wraps the GLB with proper useGltf hooks, Draco decompression, and typed node/material references. Instead of useGLTF('/models/Rock.glb') returning a raw scene you’d manually traverse, you get a <Rock /> component with full type safety.
The old Vue approach:
<script setup>
const { scene } = await useGLTF('/models/Rock.glb', { draco: true });
</script>
<template>
<primitive :object="scene" />
</template>
The new Svelte approach:
<script lang="ts">
import { T } from '@threlte/core'
import { useGltf, useDraco } from '@threlte/extras'
const gltf = useGltf<GLTFResult>('/models/Rock.glb', {
dracoLoader: useDraco()
})
</script>
<T.Group dispose={false} {...props}>
{#await gltf}
{@render fallback?.()}
{:then gltf}
<T.Mesh
geometry={gltf.nodes.Rock.geometry}
material={gltf.materials.material_1}
/>
{:catch err}
{@render error?.({ error: err })}
{/await}
</T.Group>
More boilerplate per file, but each model is now a self-contained, typed component with error handling and fallback slots. The GLTF type is extracted from the actual file — THREE.SkinnedMesh, THREE.Bone, THREE.MeshStandardMaterial — all correctly typed based on what’s actually in the GLB.
Animated Characters
The VRM characters (Cleetus, Techshaman) needed SkeletonUtils.clone for safe instancing. The generated components handle this with a cloneScene helper:
function cloneScene(scene: THREE.Group) {
const clone = cloneSkeleton(scene)
const nodes: Record<string, THREE.Object3D> = {}
clone.traverse((child) => {
if (child.name) nodes[child.name] = child
})
return nodes as unknown as GLTFResult['nodes']
}
export const { actions, mixer } = useGltfAnimations<ActionName>(
() => $gltf,
() => ref
)
$effect(() => {
$actions?.idleZero?.reset().setLoop(LoopRepeat, Infinity).play()
})
In Vue, playing animations meant manually managing currentActionName, fadeOut transitions, and idle-switch timers in useRenderLoop. In Threlte, useGltfAnimations gives you a reactive $actions object, and $effect handles the lifecycle automatically.
Svelte 5 Runes
The whole scene runs on Svelte 5 runes — $state, $derived, $effect, $props. No more shallowRef, no more useRenderLoop manual lerp. State changes are reactive by default:
let yRotation = $state(0)
let reducedMotion = $state(false)
let sparkleCount = $derived(reducedMotion ? 200 : 500)
Click events use the lowercase onclick (not on:click), props use $props() (not export let), and $effect replaces onMounted + watch + cleanup all in one.
The Clean Slate
After the initial migration, the scene went through several iterations — zone-based navigation, loading overlays, CSS3D panels, canvas texture panels. Eventually I ripped it all back to basics: a single World.svelte orchestrating models, lighting, and particles. No zones, no loading screens, no orb system. Just a rock in space with some characters and clickable 3D text.

The Deployment Gauntlet
The migration took about three weeks of on-and-off work. The deployment took one night of pure hell.
The Stack
- Server: ZimaBoard (ARM64) running Coolify
- Build: Nixpacks (later Railpack beta)
- Node: 22.x (Astro 7 requires ≥22.12.0)
The last successful deployment was on commit fbd7424 — the old Vue + Tres.js + Astro 5 stack. That build worked because Astro 5’s dependency tree didn’t include the native bindings that would become our nightmare.
Phase 1: Node Version
Nixpacks defaulted to Node 22.11.0. Astro 7 and its plugins require ≥22.12.0. A .nvmrc file pinning Node 22.23.0 fixed the version, but nixpacks didn’t always honor it. EBADENGINE warnings everywhere.
Phase 2: The Satteri Problem
@astrojs/mdx@7 depends on satteri, a native markdown processor built with NAPI-RS. Satteri ships platform-specific binaries as optional dependencies:
{
"@bruits/satteri-linux-x64-gnu": "0.9.1",
"@bruits/satteri-darwin-x64": "0.9.1",
"@bruits/satteri-darwin-arm64": "0.9.1",
"@bruits/satteri-win32-x64-msvc": "0.9.1",
"@bruits/satteri-wasm32-wasi": "0.9.1"
}
Notice anything missing? There is no @bruits/satteri-linux-arm64-gnu. The package literally doesn’t exist on npm. Satteri’s requireNative() function tries to load it, fails, falls back to WASI, and then fails again because @napi-rs/wasm-runtime (the WASI loader) isn’t installed as an explicit dependency.
This is npm/cli#4828 — npm’s optional dependency resolution is broken for cross-platform packages. The lockfile generated on x86_64 doesn’t include arm64 bindings, and npm doesn’t re-resolve them on install.
Phase 3: The Fix Chain
Each native binding had to be added as an explicit dependency in package.json:
{
"dependencies": {
"@napi-rs/wasm-runtime": "^1.1.2",
"@rolldown/binding-linux-arm64-gnu": "^1.0.3",
"@astrojs/compiler-binding-linux-arm64-gnu": "^0.2.2",
"lightningcss-linux-arm64-gnu": "^1.32.0"
}
}
Four missing native bindings. Four explicit dependencies. Same root cause, same fix pattern. The @napi-rs/wasm-runtime was the key — without it, the satteri WASI fallback couldn’t resolve the WASM module even after the .wasm file was present.
Phase 4: Railpack
After hours of fighting nixpacks Docker cache layers and --mount=type=cache stale layers, I switched to Railpack beta. Same explicit dep fixes carried over. Railpack generated a Node.js runtime image that runs npm run start — which leads to the next issue.
Phase 5: Missing Start Script
The build succeeded. 87 pages generated. The container started. Health check failed immediately.
Railpack runs npm run start. The old nixpacks setup had a start script that was removed during the migration (we tried nginx static serving). Added it back:
"start": "astro preview --port 80 --host"
Phase 6: Blocked Request
The site loaded but showed “Blocked request from blog.255242621.xyz”. Vite’s preview server validates Host headers. Astro overwrites vite.preview.allowedHosts with its own server.allowedHosts. The fix:
server: { allowedHosts: true }
Phase 7: Model Paths
Models were 404-ing in production. In dev, Astro serves files from the source filesystem, so /public/models/Rock.glb works. In the built preview server, Astro copies public/ contents to dist/ root — so public/models/Rock.glb becomes /models/Rock.glb, not /public/models/Rock.glb.
Fixed four useGltf calls across model components to use /models/ instead of /public/models/.
The Final Stack
Astro 7 + Svelte 5.53 + Threlte 8/9
├── @threlte/core 8.5
├── @threlte/extras 9.20
├── @threlte/flex 2.2
├── @threlte/gltf 3.1
├── three 0.167
├── vite 8.0 + rolldown 1.0
└── 15 GLB models → Svelte components via @threlte/gltf CLI
Hosted on a ZimaBoard (ARM64) running Coolify with Railpack beta. Built with Node 22. Served by astro preview --port 80 --host.

What I Learned
-
npm optional deps are broken on ARM64. If you’re deploying to an arm64 server and your build fails with “Cannot find native binding” or “Cannot find module ‘@something-linux-arm64-gnu’”, check if the package exists on npm. If it doesn’t, find the WASM fallback and add the WASI runtime as an explicit dep.
-
@threlte/gltfCLI is incredible. Auto-generating typed Svelte components from GLB files saves hours of manual node traversal. Each component is self-contained with proper error handling and fallback slots. -
Svelte 5 runes are a massive upgrade for 3D.
$stateand$derivedreplaceshallowRefsoup.$effectreplacesonMounted+watch+ manual cleanup. The code is shorter, more readable, and more reactive. -
Dev paths ≠ production paths.
/public/models/x.glbworks in dev,/models/x.glbworks in prod. Always test the production build locally before deploying. -
Railpack > Nixpacks for this use case. Nixpacks’ Docker cache layers (
--mount=type=cache) made it nearly impossible to force a clean dependency resolution. Railpack’s simpler build pipeline avoided the stale cache problem entirely. -
2am debugging makes you creative. That postinstall script that downloads a WASM binary via curl and extracts it into
node_modules? That happened at 2am. And it worked.
The Site
blog.255242621.xyz — a 3D world rendered in WebGL, built with Svelte 5 + Threlte, served from an ARM64 board sitting on a desk.
Thanks to OpenCode + DeepSeek V4 for carrying the late shift when the pink clown was running in circles on the satteri WASM binding. Real ones know.
