How I Finally Cracked the Code on Three.js + Sanity CMS Integration

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:

[📸 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:

[📸 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:

[📸 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:

[📸 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:

[📸 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:

[📸 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:

⚠️ 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:

[📸 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:

[📸 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:

[📸 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:

[📸 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:

  1. Framer Motion magic - Adding smooth page transitions and micro-interactions
  2. PWA superpowers - Making this work offline for those coffee shop moments
  3. Analytics deep dive - Understanding how users interact with the 3D elements
  4. 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:

📚 Helpful resources:

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.