Why SolidStart
I needed SSR for SEO -- this is a portfolio site, and search engines need to see real HTML on first load. That narrowed the field to full-stack frameworks with server-side rendering. Next.js, Remix, Astro, and SolidStart all qualify. I chose SolidStart because the site runs Three.js animations on every page, and SolidJS's fine-grained reactivity makes that significantly easier.
SolidJS has no virtual DOM. State changes update the exact DOM nodes that depend on them, nothing else. When you are running a 60fps render loop alongside UI state changes, the last thing you want is a reconciliation pass diffing a virtual tree on every frame. React's concurrent mode helps, but it is a mitigation for a problem Solid does not have.
The practical benefits compound. SolidStart ships smaller bundles than Next.js or Remix equivalents -- the runtime is ~7KB gzipped versus React's ~40KB+. For a portfolio site where first impressions matter, that overhead difference is real. The Vinxi/Nitro server layer gives me file-based routing, API endpoints, and deployment adapters without opinions about data fetching patterns.
The trade-offs are real too. SolidJS's ecosystem is smaller. Some npm packages assume React. Documentation is thinner, and when something breaks, Stack Overflow has fewer answers. solid-three (the Three.js integration library) is abandoned at v0.2.0 -- which directly led to my next decision.
Raw Three.js with onMount/onCleanup
Since solid-three is unmaintained and react-three-fiber requires React (which defeats the purpose of choosing Solid), I went with raw Three.js. This turned out to be a better pattern than I expected.
SolidJS lifecycle hooks map cleanly to Three.js setup and teardown. onMount initializes the renderer, camera, and scene. onCleanup disposes geometries, materials, and textures. There is no wrapper layer guessing at my intentions, no abstraction translating between a reactive component model and an imperative WebGL API. The code reads as straightforward Three.js with Solid managing when it starts and stops.
onMount(() => {
renderer = new THREE.WebGLRenderer({ canvas: canvasRef, alpha: true });
scene = new THREE.Scene();
// ... setup
animate();
});
onCleanup(() => {
renderer.dispose();
scene.traverse(disposeMesh);
});
Full control. No surprises.
Single Canvas, Scene Swapping
Browsers enforce a hard limit on WebGL contexts -- typically 8 to 16 per page. If each section of the site has its own canvas and renderer, you hit context exhaustion fast. The browser starts silently losing older contexts, and your hero animation goes black while you are scrolling through the projects section.
The solution is one persistent <canvas> element with a scene manager that swaps Three.js scenes based on scroll position. An IntersectionObserver watches section boundaries. When a section enters the viewport, its scene loads and renders. When it leaves, the scene is paused (not disposed -- disposal happens on route change).
Scenes lazy-load with a 200px rootMargin, so the next scene initializes just before the user scrolls to it. The hero scene is the exception -- it loads immediately but defers initialization to requestIdleCallback so it does not block hydration. Content renders first, 3D arrives after.
Mobile: Static Fallback by Default
Mobile GPUs are inconsistent. A Snapdragon 8 Gen 3 handles WebGL fine. A three-year-old budget phone does not. Rather than building a performance detection system that guesses wrong half the time, I default to a CSS gradient fallback on mobile. It looks clean. It loads fast. No janky 15fps particle fields.
Users who want 3D on mobile can enable it with a toggle. prefers-reduced-motion disables all animations entirely, replacing them with simple opacity fades. The content is the same either way -- 3D is enhancement, not content.
Content Pipeline: Markdown + YAML at Build Time
The .dev site (notes, runbooks, architecture docs) uses Markdown processed through a unified/remark/rehype pipeline at build time. The .com site keeps project data in YAML files validated with Zod schemas. Both approaches produce static data -- no filesystem access at runtime.
This was a hard requirement after migrating from Docker/VPS to Cloudflare Workers. Workers have no filesystem. The original content loader used fs.readFileSync and glob to find Markdown files at request time. That had to be rewritten to use globalThis._importMeta_.glob, which resolves file paths at build time and bundles the content into the deployment artifact. Not a trivial migration, but the result is a $0/month hosting bill with edge deployment in 300+ locations.
Monorepo: Turborepo + pnpm
Two sites sharing one brand. valkyrienexus.com and valkyrienexus.dev share design tokens, UI components, and Three.js utilities through a Turborepo monorepo with pnpm workspaces. Four shared packages: brand (colors, typography, spacing), shared (common Solid components), three-utils (scene manager, renderer setup, particle systems), and tsconfig (shared TypeScript configuration).
Each site deploys independently to Cloudflare Workers. A change to packages/brand triggers rebuilds for both apps. A change to apps/com only rebuilds the .com site. Turborepo's task graph handles this without manual dependency tracking.
What I Would Reconsider
If I were starting today, I would evaluate Astro with Solid islands more seriously. Astro's content collections handle the Markdown pipeline natively, and the island architecture could isolate Three.js to specific interactive sections without shipping the Solid runtime for static content. The trade-off is less control over the full-page 3D experience -- scene swapping across route transitions gets complicated when your framework is rendering islands instead of full pages.
SolidStart was the right call for a site where 3D is a first-class concern. For a content-heavy site with occasional interactivity, Astro probably wins.