BoldPixel Media icon
engineeringnext.jsfirebasethree.js

Our Stack: Next.js, Firebase, and Three.js

BoldPixel Media · March 1, 2026 · 8 min read



title: "Our Stack: Next.js, Firebase, and Three.js" date: "2026-03-01" excerpt: "Why we chose Next.js for static export, Firebase for hosting and backend, and Three.js for the 3D hero - and what we'd do differently if we started today." tags: ["engineering", "next.js", "firebase", "three.js"] author: "BoldPixel Media"

Every stack is a set of bets. You're betting that the tools you choose today will still be the right tools when your product is ten times larger, when the team doubles, when requirements change in ways you didn't anticipate.

We've made our bets. Here's why, and what we've learned so far.

The Constraints That Shaped Everything

Before we talk about individual tools, it's worth explaining the constraint that drove most of our architecture decisions: we needed a stack that a small team could own completely.

No dedicated DevOps. No separate backend team. No infrastructure that requires a specialist to debug at 11pm when something breaks. The ideal stack for BoldPixel Media is one where any engineer can hold the whole system in their head and trace a bug from the UI to the database without switching contexts five times.

That constraint eliminated a lot of options. It made others obvious.

Why Next.js

Next.js is the easiest choice to explain. We're building React applications. The React ecosystem is massive, well-documented, and deeply familiar to us. Within that ecosystem, Next.js is the production-grade option that doesn't require reinventing routing, bundling, image optimization, or metadata handling.

The specific feature that sold us is output: 'export' - static export mode. BoldPixel Media's main site and product landing pages are essentially static. They have no real-time data, no per-request server logic, no authentication-gated content. Generating them at build time and serving them as flat HTML from a CDN is the fastest, cheapest, and most reliable architecture available.

// next.config.mjs
const nextConfig = {
  output: 'export',
  images: {
    unoptimized: true,  // required for static export
  },
  trailingSlash: true,
}

Static export means zero server costs for the marketing site and zero cold starts. Pages load instantly because they're just files. Security surface is minimal because there's nothing to attack. Deployments are fast because there's no server to restart.

We did run into one sharp edge worth knowing about: we're on Next.js 15, not 16. There's an outstanding bug in Firebase Hosting + Next.js 16 with static export that was a dealbreaker. Next.js 15 has been rock-solid for us.

Why Firebase

Firebase is a polarizing choice in engineering circles. Developers who've been burned by vendor lock-in or unexpected billing spikes have strong opinions about it. We went in with eyes open.

The honest answer is that Firebase does three things exceptionally well for a small team:

Firebase Hosting is genuinely excellent for static sites. CDN-backed, global, fast deploys, custom domains with automatic HTTPS, and it integrates perfectly with our Next.js static export workflow. firebase deploy --only hosting and the site is live worldwide in under 60 seconds.

Firebase Functions handles everything that can't be static - contact form processing, email delivery, backend logic for our apps. The v2 Functions API is clean, and the local emulator means we can develop and test functions offline without burning through invocations.

Firestore is the database for our products. It's not right for every use case - the document/collection model takes some getting used to, and complex relational queries are genuinely awkward. But for the kind of data our products have (user profiles, story sessions, anonymous posts), it's a natural fit. Real-time listeners, offline support, and scaling handled automatically.

Here's the Firebase Functions pattern we use for the contact form:

import { onRequest } from 'firebase-functions/v2/https'
import { defineSecret } from 'firebase-functions/params'
import * as nodemailer from 'nodemailer'
 
const emailUser = defineSecret('EMAIL_USER')
const emailPass = defineSecret('EMAIL_PASS')
 
export const sendContactEmail = onRequest(
  { secrets: [emailUser, emailPass], cors: ['https://boldpixelmedia.com'] },
  async (req, res) => {
    const transporter = nodemailer.createTransport({
      service: 'gmail',
      auth: {
        user: emailUser.value(),
        pass: emailPass.value(),
      },
    })
 
    await transporter.sendMail({
      from: req.body.email,
      to: emailUser.value(),
      subject: `BoldPixel contact: ${req.body.subject}`,
      text: req.body.message,
    })
 
    res.json({ success: true })
  }
)

Secrets are managed via firebase functions:secrets:set, not .env files - the v2 pattern is correct; functions.config() is the legacy approach and shouldn't be used for new projects.

The vendor lock-in concern is real. If we needed to move off Firebase tomorrow, Firestore and Firebase Auth would require migration work. We've accepted that tradeoff in exchange for the productivity gains at our current scale. If we hit scale that makes Firebase economics painful, that's a good problem to have.

Why Three.js (and React Three Fiber)

This is the most "optional" choice in our stack - and the one that has the highest impact on first impressions.

The 3D hero on the BoldPixel Media homepage is the first thing visitors see. It's a real-time particle system running at 60fps in the browser, built with @react-three/fiber (React Three Fiber), which is a React renderer for Three.js.

Why bother? Because first impressions are load-bearing. When someone lands on a company's website, they make a judgment in the first few seconds about whether this company has craft and taste. A generic Tailwind landing page with stock photos sends one signal. An interactive 3D scene sends another.

The Three.js scene setup is simpler than it looks from the outside:

// components/NodesCanvas.tsx
'use client'
 
import { Canvas } from '@react-three/fiber'
import { useRef, useMemo } from 'react'
import { useFrame } from '@react-three/fiber'
import * as THREE from 'three'
 
function ParticleField({ count = 800 }) {
  const mesh = useRef<THREE.Points>(null)
 
  const positions = useMemo(() => {
    const arr = new Float32Array(count * 3)
    for (let i = 0; i < count; i++) {
      arr[i * 3]     = (Math.random() - 0.5) * 20  // x
      arr[i * 3 + 1] = (Math.random() - 0.5) * 20  // y
      arr[i * 3 + 2] = (Math.random() - 0.5) * 10  // z
    }
    return arr
  }, [count])
 
  useFrame((state) => {
    if (mesh.current) {
      mesh.current.rotation.y = state.clock.elapsedTime * 0.05
    }
  })
 
  return (
    <points ref={mesh}>
      <bufferGeometry>
        <bufferAttribute attach="attributes-position" args={[positions, 3]} />
      </bufferGeometry>
      <pointsMaterial size={0.05} color="#00D4FF" transparent opacity={0.7} />
    </points>
  )
}
 
export function NodesCanvas() {
  return (
    <Canvas camera={{ position: [0, 0, 8], fov: 60 }}>
      <ParticleField count={800} />
    </Canvas>
  )
}

React Three Fiber makes Three.js feel like React. Components, hooks, declarative scene graphs. useFrame is the animation loop. useMemo handles expensive geometry computation. The mental model is familiar if you know React, and it keeps the 3D code from becoming an isolated black box that only one person on the team can modify.

The main consideration is bundle size - Three.js is large. We use dynamic imports to ensure the 3D canvas doesn't block the critical rendering path:

const NodesCanvas = dynamic(
  () => import('./NodesCanvas').then(m => m.NodesCanvas),
  { ssr: false }  // Three.js requires browser APIs
)

ssr: false is required - Three.js touches window and WebGL context on import, neither of which exists during server-side rendering. Dynamic import with ssr: false defers the entire canvas to the client.

How They Fit Together

The three tools form a clean separation of concerns:

  • Three.js handles presentation and first impressions. It's a pure frontend concern, never touches data.
  • Next.js handles routing, page generation, and the React component tree. It coordinates everything.
  • Firebase handles persistence and backend logic. It's the infrastructure layer.

The dependency only goes one direction: Next.js calls Firebase; nothing calls back. Three.js doesn't know Firebase exists. Firebase doesn't know Three.js exists. The coupling is minimal, which makes each layer independently replaceable if we need to swap it out.

What We'd Do Differently

The honest retrospective:

On Next.js: No changes. The static export approach has been exactly right. The one thing we'd do from day one is write next.config.mjs (ESM) rather than next.config.js - MDX tooling is ESM-only, and migrating config format mid-project adds friction.

On Firebase: We'd be more deliberate about Firestore data modeling earlier. Document databases punish you for upfront laziness in ways relational databases don't. A few collections that made sense at the start became awkward as features grew. Model for your queries, not for your objects.

On Three.js: We'd invest earlier in @react-three/drei, which is a utility library that sits on top of React Three Fiber and handles common patterns (orbit controls, text rendering, environment maps) so you're not reinventing them. We added it partway through and it cleaned up a lot of boilerplate.

The Bigger Point

No stack is perfect. Every tool is a tradeoff. The question isn't "which tools have no downsides" - it's "which tools have downsides we can live with and upsides we can't build without."

For a small, craft-obsessed team building premium products, this stack has been the right answer. Fast deploys, minimal infrastructure surface, a React mental model throughout, and the ability to ship a 3D hero that makes people stop and actually look at the page.

That last one, honestly, might be the most important. You can't build beautiful products on infrastructure that doesn't care about the output.

We do.