
How I Finally Cracked the Code on Three.js + Sanity CMS Integration
After countless late nights and way too much coffee, I figured out how to seamlessly blend 3D web experiences with headless CMS magic. Here is my complete journey.
Okay, so picture this: It’s 2 AM, I’ve got my fourth cup of coffee, and I’m staring at my screen wondering why my 3D model looks like a pixelated mess while my CMS data refuses to cooperate. Sound familiar?
I’ve been obsessed with creating stunning 3D web experiences ever since I discovered Three.js, but managing content for a portfolio was always a pain. Static JSON files? Nope. Hardcoded data? Definitely not scalable. I needed something that would let me focus on the creative stuff while keeping content management simple.
That’s when I discovered this magical combination that changed everything: Three.js + Sanity CMS + Next.js. And after spending way too many nights figuring this out (seriously, my sleep schedule was destroyed), I finally cracked the code.
Why I Chose This Stack (After Trying Everything Else)
Here’s the thing - I didn’t just randomly pick these technologies. I actually tried a bunch of different approaches first:
- Three.js - Because let’s be honest, nothing beats those jaw-dropping 3D visuals that make people go “whoa”
- Sanity CMS - Free tier that actually works, real-time collaboration, and the most intuitive content studio I’ve ever used
- Next.js - SSR for SEO wins, incredible performance optimizations, and deployment on Vercel is chef’s kiss
- Performance - This combo loads fast on mobile (trust me, I tested it on my ancient phone)
[📸 Image suggestion: Screenshot of the final portfolio showing a 3D model smoothly rotating with project cards below it]
My Battle-Tested Tech Stack
After trying React Three Fiber, Strapi, and even WordPress (don’t judge me), here’s what actually worked:
{
"frontend": "Next.js 13",
"3d": "Three.js",
"cms": "Sanity CMS",
"styling": "Chakra UI",
"deployment": "Vercel"
}
💡 Quick tip: I initially tried React Three Fiber, but vanilla Three.js gave me more control over performance optimizations. Sometimes the simpler approach wins!
Part 1: Setting Up Three.js - The Foundation
Alright, let’s dive into the fun part! This is where I spent most of my debugging time, so I’ll share all the gotchas I discovered.
Installing Dependencies (The Right Way)
Here’s where my journey really began. I remember staring at the Three.js docs, feeling completely overwhelmed. But once I broke it down, it’s actually pretty straightforward.
The first thing I learned (after hours of trial and error) is that you absolutely need these specific dependencies. I initially tried to skip the TypeScript types, but trust me, your future self will thank you for including them:
npm install three @types/three
⚠️ Warning: If you’re using Vite (which I highly recommend), you might run into some module resolution issues. I’ll show you how to fix that in the Next.js config section.
[📸 Image suggestion: Screenshot of package.json showing the installed dependencies with version numbers]
Creating the 3D Component - Where the Magic Happens
This is the heart of our 3D experience. I probably rewrote this component 10 times before getting it right. Here’s the version that actually works (and doesn’t crash on mobile):
// components/voxel-pc.js
import { useState, useEffect, useRef, useCallback } from 'react';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
import { loadGLTFModel } from '../lib/model';
const VoxelPc = () => {
const refContainer = useRef();
const [loading, setLoading] = useState(true);
const refRenderer = useRef();
const urlPcGLB = '/pc.glb';
useEffect(() => {
const { current: container } = refContainer;
if (container) {
const scW = container.clientWidth;
const scH = container.clientHeight;
// Setup renderer
const renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
});
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(scW, scH);
container.appendChild(renderer.domElement);
refRenderer.current = renderer;
// Create scene
const scene = new THREE.Scene();
// Setup camera
const camera = new THREE.OrthographicCamera(-5, 5, 5, -5, 0.01, 50000);
camera.position.set(10, 10, 10);
camera.lookAt(0, 0, 0);
// Add lighting
const ambientLight = new THREE.AmbientLight(0xcccccc, Math.PI);
scene.add(ambientLight);
// Setup controls
const controls = new OrbitControls(camera, renderer.domElement);
controls.autoRotate = true;
// Load 3D model
loadGLTFModel(scene, urlPcGLB)
.then(() => {
setLoading(false);
animate();
})
.catch(error => console.error('Error loading model:', error));
// Animation loop
const animate = () => {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
};
// Cleanup
return () => {
renderer.domElement.remove();
renderer.dispose();
};
}
}, []);
return (
<div ref={refContainer} style={{ width: '100%', height: '400px' }}>
{loading && <div>Loading 3D model...</div>}
</div>
);
};
export default VoxelPc;
📝 What I learned the hard way:
- OrthographicCamera vs PerspectiveCamera: I initially used PerspectiveCamera, but OrthographicCamera gives that clean, isometric look that works better for portfolio pieces
- devicePixelRatio: Don’t skip this! It makes your 3D models look crisp on high-DPI displays
- Cleanup function: Super important to avoid memory leaks, especially in React StrictMode
[📸 Image suggestion: Split screen showing the 3D model rendering in the browser alongside the component code in VS Code]
GLTF Model Loader - The Unsung Hero
This little utility function caused me more headaches than I care to admit. The key insight? Always use Promises and handle errors gracefully. Here’s the version that actually works:
// lib/model.js
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
export function loadGLTFModel(scene, glbPath) {
return new Promise((resolve, reject) => {
const loader = new GLTFLoader();
loader.load(
glbPath,
gltf => {
const obj = gltf.scene;
obj.name = 'pc';
obj.position.set(0, 0, 0);
obj.scale.setScalar(4.5);
scene.add(obj);
resolve(obj);
},
undefined,
error => reject(error)
);
});
}
🔧 Pro tip: The scale.setScalar(4.5)
value took me forever to get right. Too small and your model disappears, too large and it clips out of view. Start with 1.0 and adjust based on your model size.
[📸 Image suggestion: File explorer showing the lib folder structure with model.js highlighted]
Part 2: Setting Up Sanity CMS - Content Management Bliss
Okay, confession time: I used to hardcode all my portfolio data in JSON files. Yeah, I know, I know. But Sanity changed everything. The free tier is generous, the Studio interface is gorgeous, and real-time updates? Chef’s kiss
Installing Sanity (The Easy Part)
This is straightforward, but I’ll share the gotcha I discovered:
npm install @sanity/client @sanity/image-url
📝 Note: Make sure you’re using the v2 client syntax. The v3 syntax is different and will break things (learned this the hard way during an upgrade).
[📸 Image suggestion: Screenshot of the Sanity Studio interface showing the project dashboard]
Client Configuration - The Bridge Between Worlds
This is where Sanity connects to your frontend. I spent way too much time debugging API calls before I got this config right:
// lib/sanity.js
import { createClient } from '@sanity/client';
import imageUrlBuilder from '@sanity/image-url';
const config = {
projectId: 'your-project-id',
dataset: 'production',
apiVersion: '2024-01-01',
useCdn: process.env.NODE_ENV === 'production',
};
const client = createClient(config);
const builder = imageUrlBuilder(client);
export const urlFor = source => {
return builder.image(source).auto('format').fit('max');
};
export default client;
// Project queries
export const queries = {
projects: `*[_type == "project"] | order(order asc) {
_id,
title,
slug,
description,
year,
stack,
image {
asset->{
_id,
url
},
alt
}
}`,
project: `*[_type == "project" && slug.current == $slug][0] {
_id,
title,
slug,
description,
year,
stack,
website,
image,
gallery[],
content
}`,
};
⚠️ Critical gotcha: The useCdn: process.env.NODE_ENV === 'production'
line is crucial. I spent hours debugging why my content wasn’t updating in development before I realized the CDN was caching everything.
📝 GROQ Query Tips:
- Always include
_id
for React keys - Use
order(order asc)
to let content editors control display order - The
slug.current
syntax tripped me up initially - don’t forget that.current
part!
[📸 Image suggestion: Screenshot of the Sanity Studio query tool showing a GROQ query in action]
Project Schema - The Content Structure
This is where Sanity really shines. I love how you can define your content structure with code, and it automatically generates a beautiful editing interface:
// sanity/schemas/project.js
export default {
name: 'project',
title: 'Projects',
type: 'document',
fields: [
{
name: 'title',
title: 'Title',
type: 'string',
validation: Rule => Rule.required(),
},
{
name: 'slug',
title: 'Slug',
type: 'slug',
options: {
source: 'title',
maxLength: 96,
},
validation: Rule => Rule.required(),
},
{
name: 'description',
title: 'Description',
type: 'text',
validation: Rule => Rule.required(),
},
{
name: 'image',
title: 'Thumbnail Image',
type: 'image',
options: {
hotspot: true,
},
fields: [
{
name: 'alt',
type: 'string',
title: 'Alternative text',
},
],
},
{
name: 'stack',
title: 'Tech Stack',
type: 'array',
of: [{ type: 'string' }],
options: {
layout: 'tags',
},
},
{
name: 'year',
title: 'Year',
type: 'number',
validation: Rule => Rule.required(),
},
{
name: 'website',
title: 'Website URL',
type: 'url',
},
],
preview: {
select: {
title: 'title',
media: 'image',
subtitle: 'year',
},
},
};
💡 Schema design insights:
- The
hotspot: true
option on images is a game-changer for responsive designs layout: 'tags'
makes the tech stack super easy to manage- The
preview
configuration makes your content list look professional
[📸 Image suggestion: Side-by-side comparison of the schema code and the resulting Sanity Studio interface]
Part 3: Bringing It All Together - The Grand Finale
This is where everything clicks. After all the setup, seeing your 3D model and CMS content working together is pure magic. Here’s how I structured my home page:
Home Page with 3D Model - The Showstopper
// pages/index.js
import { Container, Heading, Box, Button } from '@chakra-ui/react';
import Layout from '../components/layouts/article';
import VoxelPc from '../components/voxel-pc';
import Link from 'next/link';
const Home = () => (
<Layout>
<Container>
<Box textAlign="center" mb={6}>
<Heading as="h1" size="xl" mb={4}>
Tomás Maritano
</Heading>
<p>Developer / Designer / Entrepreneur</p>
</Box>
<Box mb={8}>
<Heading as="h2" size="lg" mb={4}>
About Me
</Heading>
<p>
I'm an indie developer with 7+ years of experience, specializing in full-stack development
and product management.
</p>
<Box mt={4}>
<Button as={Link} href="/works" colorScheme="teal">
View My Portfolio
</Button>
</Box>
</Box>
<Box textAlign="center">
<VoxelPc />
</Box>
</Container>
</Layout>
);
export default Home;
🎨 Design decisions I made:
- Center-aligned layout: The 3D model deserves to be the hero element
- Minimal content above: Don’t compete with your 3D showcase
- Strategic CTA placement: Guide users to your portfolio after they’re hooked
[📸 Image suggestion: Screenshot of the complete home page showing the 3D model integrated with the layout]
Projects Page - Where CMS Shines
This is where all that Sanity setup pays off. Dynamic content, optimized images, and clean code:
// pages/works.js
import { Container, Heading, SimpleGrid } from '@chakra-ui/react';
import Layout from '../components/layouts/article';
import { WorkGridItem } from '../components/grid-item';
import client, { queries, urlFor } from '../lib/sanity';
const Works = ({ projects = [] }) => {
return (
<Layout title="Works">
<Container>
<Heading as="h1" mb={6}>
Projects
</Heading>
<SimpleGrid columns={[1, 1, 2]} gap={6}>
{projects.map(project => {
const thumbnail = project.image
? urlFor(project.image).width(400).height(225).url()
: '/fallback-image.jpg';
return (
<WorkGridItem
key={project._id}
id={project.slug.current}
title={project.title}
thumbnail={thumbnail}
>
{project.description}
</WorkGridItem>
);
})}
</SimpleGrid>
</Container>
</Layout>
);
};
export async function getStaticProps() {
try {
const projects = await client.fetch(queries.projects);
return {
props: { projects },
revalidate: 60,
};
} catch (error) {
console.error('Error fetching projects:', error);
return {
props: { projects: [] },
};
}
}
export default Works;
🔥 ISR (Incremental Static Regeneration) magic:
revalidate: 60
means your content updates every minute- The fallback array ensures your site never crashes if Sanity is down
- Error handling is crucial for production apps
[📸 Image suggestion: Screenshot of the projects page showing the grid layout with project thumbnails]
Part 4: Performance Optimizations - The Secret Sauce
Here’s where I learned some hard lessons about performance. Your 3D model is awesome, but if it kills your loading time, nobody will stick around to see it.
Lazy Loading for 3D - Save Those Precious Bytes
// components/lazy-voxel.js
import dynamic from 'next/dynamic';
const LazyVoxelPc = dynamic(() => import('./voxel-pc'), {
ssr: false,
loading: () => <div>Loading 3D model...</div>,
});
export default LazyVoxelPc;
⚡ Why this matters:
- First Paint: Your page loads instantly, 3D loads after
- SEO: Search engines don’t wait for 3D models
- Mobile: Saves bandwidth on slower connections
⚠️ Common mistake: I initially forgot ssr: false
and got hydration errors. Three.js needs the window object!
Image Optimization - Sanity + Next.js = Perfect Combo
This is where Sanity’s image pipeline really shines. Automatic format conversion, resizing, and optimization:
// components/optimized-image.js
import Image from 'next/image';
import { urlFor } from '../lib/sanity';
const OptimizedImage = ({ image, alt, width = 800, height = 600 }) => {
if (!image?.asset) return null;
const imageUrl = urlFor(image).width(width).height(height).quality(85).url();
return (
<Image
src={imageUrl}
alt={alt || 'Image'}
width={width}
height={height}
placeholder="blur"
blurDataURL="data:image/svg+xml;base64,..."
/>
);
};
export default OptimizedImage;
📸 Image optimization wins:
- Automatic WebP/AVIF: Sanity serves modern formats when supported
- Lazy loading: Next.js Image component handles this beautifully
- Blur placeholder: Smooth loading experience
[📸 Image suggestion: Performance comparison showing before/after Lighthouse scores]
Next.js Configuration - The Glue That Binds Everything
This config took me forever to get right. Here’s what actually works:
// next.config.js
module.exports = {
reactStrictMode: true,
images: {
domains: ['cdn.sanity.io'],
},
env: {
SANITY_PROJECT_ID: process.env.SANITY_PROJECT_ID,
},
};
🔧 Configuration gotchas:
- domains: Don’t forget to whitelist Sanity’s CDN
- reactStrictMode: Essential for catching bugs early
- env: Expose only what you need to the client
[📸 Image suggestion: VS Code screenshot showing the next.config.js file with syntax highlighting]
Environment Variables - Keep Your Secrets Safe
Never commit your project IDs to git. Here’s how to handle them properly:
# .env.local
SANITY_PROJECT_ID=your-project-id
SANITY_DATASET=production
🔒 Security reminder:
- Always use
.env.local
for sensitive data - Add
.env.local
to your.gitignore
- Use different project IDs for staging and production
[📸 Image suggestion: File explorer showing .env.local file with important files highlighted]
What I Achieved (And You Can Too!)
After all those late nights and debugging sessions, here’s what this stack delivered:
🎆 The wins:
- Mind-blowing visuals - That 3D model makes people stop scrolling
- Content freedom - I can update projects from anywhere with Sanity’s mobile app
- Lightning fast - 95+ Lighthouse scores across the board
- SEO that works - Google loves the SSR + structured data combo
- Future-proof - This stack scales beautifully as your portfolio grows
[📸 Image suggestion: Screenshot of Lighthouse performance scores showing 95+ ratings]
What’s Next on My Roadmap
I’m never done experimenting. Here’s what I’m planning:
- Framer Motion magic - Adding smooth page transitions and micro-interactions
- PWA superpowers - Making this work offline for those coffee shop moments
- Analytics deep dive - Understanding how users interact with the 3D elements
- Global reach - i18n for Spanish and maybe Japanese content
💡 Pro tip: Start with what I’ve shown you, then add these features one by one. Don’t try to build everything at once (trust me, I tried).
Let’s Build Something Amazing Together
Here’s the thing - this tutorial is just the beginning. I’ve shared everything I learned, but I know you’re going to take this in directions I never imagined. And that’s exactly what I want to see!
🔥 I want to see what you build:
- Drop a comment with your portfolio URL when you’re done
- Tag me @hacklabdog on Twitter with your creations
- Found a better way to do something? Share it! The community grows when we all contribute
📚 Helpful resources:
- Complete source code - Fork it, improve it, make it yours
- Three.js documentation - Your new best friend
- Sanity’s incredible guides - They have tutorials for everything
Questions? Stuck somewhere? Don’t suffer in silence! Reach out on Twitter or check the GitHub issues. The dev community is incredibly helpful, and I try to respond to everyone.
Found this helpful? Share it with other developers who might be struggling with the same challenges. We’re all in this together!
Keep building amazing things. The web needs more creators like you.