Static screenshots rarely capture a developer's range, but a real-time 3D portfolio does. By blending interactive scenes with dynamic content, you create an experience that feels more like a product demo than a résumé. This approach combines animated WebGL backgrounds, smooth camera transitions, and project data served on demand.
The stack is purpose-built for speed and maintainability. Vite's instant server start and hot-module replacement keep iteration tight during 3D tweaking sessions with lean production bundles.
React supplies the familiar component model, and React Three Fiber lets components describe Three.js scenes declaratively. Strapi v5 rounds out the stack with a TypeScript codebase, flattened API, and versioned documents that make content edits painless.
In Brief:
- Combine Vite's fast development, React Three Fiber's declarative 3D, and Strapi v5's clean APIs for interactive portfolios
- Three.js handles WebGL complexity while React components manage scene state and user interactions
- Strapi v5's Document System tracks content versions with flattened API responses that simplify data consumption
- Performance optimization through proper asset management, cleanup functions, and mobile-responsive 3D rendering
Technology Stack Overview
Building a 3D portfolio that loads quickly and stays maintainable requires tools that solve specific challenges while working together effectively:
- Vite - Accelerates development with instant server start, native ES module loading, and hot module replacement
- React and React Three Fiber - Structures the interface with familiar component patterns while making Three.js scenes declarative
- Three.js - Renders interactive 3D visuals with scene management, camera controls, and WebGL optimization
- Strapi v5 - Manages content with the Document System, TypeScript codebase, and flattened API responses
Together, these tools minimize latency on both sides: Vite optimizes the JS payload, Three.js focuses GPU work, and Strapi serves clean JSON that your React components can hydrate without extra parsing. The tight feedback loop—code, refresh, see the change—lets you fine-tune animations, lighting, or copy in seconds instead of minutes.
Part 1: Setting Up the Backend with Strapi v5
Let's establish a CMS foundation for your 3D portfolio with Strapi's newest version.
Installation and Content Architecture
Strapi v5 handles rapid front-end iterations through its improved content management system. Ensure you're running Node.js 18 or later—earlier versions break the new Vite-based build chain during installation.
1# Generate a project
2npx create-strapi@latest 3d-portfolio-cms --quickstart
The quickstart script configures SQLite, installs dependencies, and launches the Admin Panel at http://localhost:1337/admin
. Create an admin user when prompted and store the credentials securely.
Next, map your content to portfolio requirements in the Admin Panel. Create two content types: Projects as a Collection Type for multiple entries and About as a Single Type for biographical data. This structure separates repeatable work samples from static information, with each model definition stored in src/api/*/content-types/*/schema.json
for straightforward version control.
Strapi v5's Document System tracks every edit, draft, and publish state without plugins. Version diffing and one-click rollbacks become standard workflow—essential when portfolio items evolve over time.
Common installation issues include port conflicts (set STRAPI_PORT=1338
in .env
), wrong Node versions (run nvm use 18
), and SQLite write permissions (on Linux, run chmod 755
on the project folder).
Configuring Collections and API Setup
In the Content-Type Builder, create the Projects collection with these fields:
title
(Text, required)description
(Rich Text)images
(Media, multiple)technologies
(Component or Text list)project_url
(Text, URL)featured
(Boolean)
Save the changes—Strapi hot-restarts and exposes API endpoints like /api/projects
.
Create the About Single Type with fields for name
, bio
, profile_picture
, and social links. The singleton structure prevents accidental duplicates.
Configure permissions in Settings → Roles → Public by enabling find and findOne for both content types. Test the API response structure:
1curl http://localhost:1337/api/projects
You’ll reuse that command while reviewing the REST API documentation for filtering and pagination examples.
Strapi v5 returns flattened responses with attributes like title
at the root of each object—eliminating the nested data.attributes
structure that required additional client-side mapping.
The Draft & Publish system integrates with the Document System. Stage project entries as drafts and publish when ready. Your backend now provides content versioning for every change, clean REST endpoints for Projects and About, and properly configured public read permissions.
Part 2: Frontend Foundation with Vite and React
Vite eliminates the lengthy build times associated with Create React App, starting the development server instantly and providing hot-module replacement that updates changed files in milliseconds.
Start by scaffolding the project:
1npm create vite@latest my-portfolio -- --template react
2cd my-portfolio
3npm install
Install the required dependencies:
1npm install three @react-three/fiber @react-three/drei axios
@react-three/fiber
enables 3D scene authoring as React components, while Drei provides helpers like OrbitControls
and PerspectiveCamera
for faster prototyping. Axios handles network requests to Strapi.
Configure SWC for faster transforms:
1// vite.config.js
2import { defineConfig } from 'vite';
3import react from '@vitejs/plugin-react-swc';
4
5export default defineConfig({
6 plugins: [react()],
7});
Vite treats imported assets as first-class modules. Place GLB models or HDR textures in src/assets
and reference them directly in your code. Environment variables use the VITE_
prefix—store your Strapi API URL here rather than hard-coding it.
Organize your code to separate 3D logic from UI concerns using this directory structure:
1src/
2 components/ // React UI
3 scenes/ // Three.js objects & lights
4 hooks/ // shared logic (e.g., useFetch, useResize)
5 services/ // Strapi API wrappers
6 assets/ // models, textures, images
Start with App.jsx
by mounting a <Canvas>
from React Three Fiber:
1import { Canvas } from '@react-three/fiber';
2import Scene from './scenes/Scene';
3import Projects from './components/Projects';
4
5export default function App() {
6 return (
7 <>
8 <Canvas shadows>
9 <Scene />
10 </Canvas>
11 <Projects />
12 </>
13 );
14}
Scene.jsx
contains cameras, lights, and background geometry. Break complex meshes into child components for memoization and reuse. UI elements like nav bars or project cards live under components/
, isolated from WebGL internals.
Keep data access isolated in services/api.js
, which wraps Axios calls to your Strapi endpoints. The file reads the base URL from import.meta.env.VITE_STRAPI_URL
, providing flexibility across environments.
Custom hooks like useResize
ensure the canvas matches viewport size—preventing letterboxing on mobile and maintaining pixel-perfect 3D rendering.
Part 3: Building the 3D Scene with Three.js
Building a solid 3D scene requires understanding Three.js fundamentals, implementing smooth animations, and maintaining performance discipline. Three.js works around three core objects: a Scene
to hold everything, a Camera
to define the viewpoint, and a Renderer
that draws to a <canvas>
.
With React, the cleanest approach is React Three Fiber's <Canvas>
component, which wraps these primitives in React's state model. Create the scene inside your component:
1import { Canvas, useFrame } from '@react-three/fiber';
2import { Suspense, useRef } from 'react';
3import { OrbitControls, useGLTF } from '@react-three/drei';
4
5function SpinningCube() {
6 const mesh = useRef();
7 useFrame((_, delta) => (mesh.current.rotation.y += delta));
8 return (
9 <mesh ref={mesh}>
10 <boxGeometry args={[1, 1, 1]} />
11 <meshStandardMaterial color="orange" />
12 </mesh>
13 );
14}
15
16export default function Scene() {
17 return (
18 <Canvas shadows camera={{ fov: 60, position: [0, 0, 4] }}>
19 <ambientLight intensity={0.4} />
20 <pointLight position={[5, 5, 5]} intensity={1} />
21 <Suspense fallback={null}>
22 <SpinningCube />
23 </Suspense>
24 <OrbitControls enablePan={false} />
25 </Canvas>
26 );
27}
React's hooks contain the imperative WebGL operations. useFrame
replaces requestAnimationFrame
, and OrbitControls provides ready-made camera handling from @react-three/drei
. Since React Three Fiber includes TypeScript support, adding a tsconfig.json
gets you autocomplete that matches Strapi's TypeScript codebase.
Animated background elements
Static scenes feel flat, so add lightweight geometry in the background—particle fields or floating spheres work well. Instancing lets the GPU draw thousands of identical meshes with a single call, keeping frame times low:
1import { InstancedMesh, SphereGeometry, MeshBasicMaterial, Matrix4 } from 'three';
2import { useEffect, useRef } from 'react';
3import { useThree } from '@react-three/fiber';
4
5function Particles({ count = 500 }) {
6 const meshRef = useRef();
7 const { clock } = useThree();
8
9 useEffect(() => {
10 const mesh = meshRef.current;
11 const matrix = new Matrix4();
12 for (let i = 0; i < count; i++) {
13 matrix.setPosition(
14 (Math.random() - 0.5) * 20,
15 (Math.random() - 0.5) * 20,
16 (Math.random() - 0.5) * 20
17 );
18 mesh.setMatrixAt(i, matrix);
19 }
20 mesh.instanceMatrix.needsUpdate = true;
21 }, [count]);
22
23 useFrame(() => {
24 meshRef.current.rotation.y = clock.getElapsedTime() * 0.1;
25 });
26
27 return (
28 <instancedMesh ref={meshRef} args={[null, null, count]}>
29 <sphereGeometry args={[0.05, 8, 8]} />
30 <meshBasicMaterial color="#ffffff" />
31 </instancedMesh>
32 );
33}
You can tie camera movements to scroll using a useScroll
hook, or feed mouse coordinates into shader uniforms for parallax effects. Both approaches maintain responsive interaction without increasing bundle size.
Loading assets and maintaining 60 FPS
Heavy GLB models can destroy performance. Stick to binary glTF, compress textures, and strip unused data before import. Use Fiber's <Suspense>
with useGLTF
to show a loader while models stream:
1import { Html, useProgress } from '@react-three/drei';
2
3function Loader() {
4 const { progress } = useProgress();
5 return <Html center>{progress.toFixed(0)} % loaded</Html>;
6}
Add geometry.dispose()
and material.dispose()
in a useEffect
cleanup to prevent memory leaks—WebGL won't free them automatically:
1return () => {
2 scene.remove(mesh);
3 mesh.geometry.dispose();
4 mesh.material.dispose();
5};
For large texture libraries, serve assets through Strapi's Media Library and let its CDN plugins handle caching. This reduces first-paint times and simplifies invalidation when you replace renders.
Keep runtime stats visible with r3f-perf
—it drops a HUD into the corner showing FPS, draw calls, and GL memory. Perfect for catching regressions before they ship.
Part 4: Content Integration and Dynamic Rendering
Connecting data from the Strapi API to your React scene starts with a small, reusable API layer. Create one axios instance that reads the CMS URL from an environment variable:
1// src/services/api.js
2import axios from 'axios';
3
4const api = axios.create({
5 baseURL: import.meta.env.VITE_STRAPI_URL, // e.g. http://localhost:1337/api
6 timeout: 8000,
7});
8
9const unwrap = res => res.data; // Strapi v5 returns flat `data`
10const onError = err => {
11 console.error(err);
12 throw err; // surface to UI for graceful fallback
13};
14
15export const fetchProjects = () =>
16 api.get('/projects?sort=date:desc').then(unwrap).catch(onError);
17
18export const fetchAbout = () =>
19 api.get('/about').then(unwrap).catch(onError);
Keep portfolio data in a React context to avoid prop drilling:
1// src/context/PortfolioProvider.jsx
2const PortfolioContext = createContext();
3
4export function PortfolioProvider({ children }) {
5 const [state, setState] = useState({ projects: [], about: null, loading: true });
6
7 useEffect(() => {
8 Promise.all([fetchProjects(), fetchAbout()]).then(([projects, about]) =>
9 setState({ projects, about, loading: false })
10 );
11 }, []);
12
13 return (
14 <PortfolioContext.Provider value={state}>
15 {children}
16 </PortfolioContext.Provider>
17 );
18}
The fetch runs once, so React's built-in cache (the component tree) doubles as a lightweight store. For larger portfolios, memoized selectors (useMemo
) or an external solution such as Zustand slot in cleanly.
Render content over a moving WebGL canvas without sacrificing readability. Wrap each 3D scene section in a flex container that stacks content above the <Canvas />
. Modern browsers composite layers efficiently, keeping text crisp while Three.js animates below.
A simple Projects
component maps the CMS payload to cards:
1function Projects() {
2 const { projects, loading } = useContext(PortfolioContext);
3 if (loading) return <Loader />;
4
5 return projects.map(p =>
6 p.featured ? <HeroProject key={p.id} {...p} /> : <ProjectCard key={p.id} {...p} />
7 );
8}
Featured items receive a dedicated layout (<HeroProject/>
) while the rest flow into a responsive grid. Every card is a pure component, so React's diff skips rerenders unless the Strapi entry actually changes—crucial when animations already tax the main thread.
Smooth section transitions come from the web stack, not additional shaders. Use CSS scroll-snap
for vertical paging and @react-three/fiber
's useFrame
hook to ease the camera between waypoints, keeping frame times within the 16 ms budget.
A custom useViewport
hook listens for resize events and forwards width and height to both the CSS grid and the <Canvas />
, ensuring content columns collapse gracefully while the 3D canvas scales proportionally. The result is a data-driven portfolio that stays performant, legible, and engaging across every screen.
Solving Common 3D Development and Deployment Issues
Building 3D portfolios introduces unique challenges that can derail performance, break integrations, or limit device compatibility. Here's how to identify and solve the most frequent issues.
Three.js Performance Issues
Running complex 3D scenes inside a React component can overwhelm both GPU and memory. Start by targeting 60 FPS and under 100 MB of memory. If your frame rate dips, open Chrome DevTools or drop the lightweight r3f-perf
overlay to watch real-time draw calls and memory.
Common performance killers include thousands of individual meshes (merge static geometry or switch to instancing), large uncompressed textures (compress and atlas them), and geometry that stays in memory after unmount.
In every useEffect
cleanup, call geometry.dispose()
and material.dispose()
to prevent leaks.
React re-renders can also sabotage performance. Memoize heavy components with React.memo
, and pass stable props via useCallback
or useMemo
. On mobile, the GPU budget is tighter, so test early. High-poly models that render fine on desktop can crash on phones.
Strapi v5 Integration Challenges
Strapi v5 ships a flattened JSON structure, so responses differ from earlier versions. If you migrated from v4 and your old data mappers break, inspect the new shape in /api/*
and update property paths rather than nesting under attributes
.
When the frontend receives a mysterious 403, look at two places: role permissions in the Admin Panel and the revised CORS defaults. Strapi now blocks all origins until you add yours to config/middlewares.js
. For authenticated routes, mismatched tokens now fail fast instead of being coerced.
For large images or videos, consider integrating external plugins or services to optimize media and deliver responsive formats, as Strapi's Media Library does not support this natively. While managing content and media storage, monitor your usage to avoid unexpected storage costs.
Cross-Browser and Mobile Compatibility
WebGL support varies. Detect it with a one-liner:
1const hasWebGL = !!window.WebGLRenderingContext && !!document.createElement('canvas').getContext('webgl');
If hasWebGL
is false, render a static image fallback instead of the <Canvas>
component.
Even when WebGL is available, mobile browsers have tighter shader limits. Post-processing pipelines that work on desktop can collapse on mobile due to extra render passes. Mitigate this by conditionally disabling bloom or depth-of-field when navigator.hardwareConcurrency
reports few cores.
Touch interactions need their own handlers—onPointerDown
covers both mouse and finger events across modern browsers. For layout, keep critical text outside the canvas; CSS grid makes it easy to stack UI elements above the 3D scene without affecting rendering.
When asset weight is still too high, build a stripped-down mobile bundle in Vite by toggling env variables during vite build
. You can swap heavy models for lighter LOD versions or skip secondary scenes altogether.
Shipping Your Interactive Portfolio to Production
Your build pipeline determines whether your portfolio stays fast after leaving the local dev server. Vite's vite build
command runs Rollup under the hood, applying tree-shaking, minification, and hashed filenames for far-future caching.
Enable the React SWC plugin in vite.config.js
—SWC compiles JSX faster than Babel and produces leaner bundles. For large 3D scenes, split vendor code:
1// vite.config.js
2export default defineConfig({
3 build: {
4 rollupOptions: {
5 output: {
6 manualChunks: {
7 vendor: ['react', 'three'] // keeps first load lightweight
8 }
9 }
10 }
11 }
12});
The production build drops your assets into dist/
—deploy this folder to any static host. Vercel, Netlify, or an S3 bucket served through CloudFront all work well. Strapi requires different treatment: build a container image, set NODE_ENV=production
, and mount a persistent volume for the ./public/uploads
directory.
Environment variables belong in .env
files that match the target. Prefix front-end variables with VITE_
:
1# .env.production
2VITE_API_URL=https://api.yourdomain.com
Static 3D models and high-resolution textures can crush bandwidth if delivered straight from the origin. Upload them to a CDN and reference the absolute URL in Strapi's Media Library. Vite's hashed file names make cache-busting automatic.
To keep runtime performance in check, add the lightweight r3f-perf panel in non-production builds and use Chrome DevTools' Performance tab once live. If frame rate drops in production, profile first-render chunks using Vite's built-in visualizer plugin to pinpoint oversized modules.
Lock down Strapi by disabling the default /admin
route in production, enabling HTTPS, and setting up JWT rotation. Combine that with role-based permissions on every content-type so only intended endpoints are public. Schedule regular database backups—losing version history in the Document System hurts more than a few extra gigs of storage.
Building Your Interactive 3D Portfolio with Strapi
Strapi v5 manages your content with version control and clean API responses, Vite handles fast development cycles, and React Three Fiber renders your 3D scenes without the WebGL complexity.
The Document System keeps your portfolio data organized, while Vite's instant HMR lets you iterate on both code and shaders in real-time.
Your next moves depend on your goals. Add complexity with GLTF models or particle systems, implement project filtering with Strapi's query parameters, or build a contact form using Strapi's Email plugin.
The stack you've assembled handles both rapid prototyping and production deployment. Start experimenting with your content structure and 3D interactions—you have the foundation to build something memorable.
Contact Strapi Sales
I'm a web developer and writer. I love to share my experiences and things I've learned through writing.