obOB STUDIO
Web Tech

Static Export With Next.js — Shipping a Portfolio That Loads Instantly

5 min read

No server. Seriously.

When I tell people my portfolio runs on Next.js, they assume I'm running a Node server somewhere. Nope. The entire site compiles to a folder of static HTML, CSS, and JavaScript files. I upload that folder to an S3 bucket, put CloudFront in front of it, and that's the entire infrastructure.

No server-side rendering. No API routes. No serverless functions (well, not for the portfolio itself — the game backend is separate). Just files on a CDN.

The config

The magic line is output: "export" in next.config.ts:

const nextConfig = {
  output: "export",

  // Clean folder URLs: /work/ -> /work/index.html
  trailingSlash: true,

  // Image Optimization needs a server; disable for static export
  images: { unoptimized: true },
};

That's the entire config (minus some Turbopack root pinning). Three settings:

`output: "export"` tells Next.js to run next build as a static site generator. Instead of producing a Node server, it outputs an ./out folder containing every route as a pre-rendered HTML file.

`trailingSlash: true` generates /work/index.html instead of /work.html. This maps cleanly onto S3 object keys — when CloudFront receives a request for /work/, S3 returns /work/index.html. No rewrites needed.

`images: { unoptimized: true }` disables Next.js's built-in image optimization, which requires a running server. Static images are served as-is. For a portfolio site with a handful of images, this is fine.

What you lose with static export

Let's be upfront about the trade-offs. Static export means:

  • No server-side rendering. Every page is pre-rendered at build time. Dynamic content (like fetching data from an API) has to happen client-side.
  • No API routes. You can't have /api/something. My backend is a separate AWS service.
  • No `headers()`, `redirects()`, or `rewrites()` in next.config. These are server-only features. I handle redirects in CloudFront.
  • No ISR (Incremental Static Regeneration). Pages can't be rebuilt on demand. I rebuild and redeploy the whole site when I add a new project.

For a portfolio site, none of these are deal-breakers. The project data lives in a TypeScript file (lib/projects.ts). Blog posts are in TypeScript files. Everything is known at build time.

Same-origin game embedding

The games live in /public/games/<slug>/. When Next.js builds, everything in /public is copied to ./out as-is. So a game at public/games/garden-gnomes/index.html becomes out/games/garden-gnomes/index.html.

Because the games are served from the same origin as the portfolio, iframes can communicate freely. No CORS issues. No cross-origin storage problems. This is deliberate — I tried hosting games on a separate subdomain once and ran into a wall of security restrictions.

// GameFrame component
function GameFrame({ project }) {
  return (
    <iframe
      src={embedSrc(project)}
      allow={project.iframeAllow || ""}
      style={{ width: "100%", height: "100%", border: "none" }}
    />
  );
}

The embedSrc function injects the WebSocket URL for multiplayer games. For everything else, it just returns the src path directly.

The 3D hero: React Three Fiber

The landing page features a 3D scene with floating geometric objects — one for each project. This is built with React Three Fiber (R3F), which is Three.js in React:

Each project in lib/projects.ts has a geometry field ("box", "sphere", "torus", etc.) and an accent color. The hero scene maps these to actual Three.js geometries with glossy, reflective materials. You can hover and click them to navigate to each project.

R3F works perfectly with static export because it's entirely client-side. The 3D scene mounts in the browser, not on a server. The pre-rendered HTML contains the page skeleton, and React hydrates it with the interactive 3D content.

Lenis for smooth scroll

The portfolio uses Lenis for buttery smooth scrolling. Lenis intercepts the browser's native scroll and applies its own easing — the page glides instead of jumping. It sounds unnecessary until you try it. The difference in perceived quality is huge.

import Lenis from "@studio-freight/lenis";

const lenis = new Lenis({
  duration: 1.2,
  easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
  smooth: true,
});

function raf(time) {
  lenis.raf(time);
  requestAnimationFrame(raf);
}
requestAnimationFrame(raf);

Lenis is also purely client-side, so it's compatible with static export.

Performance numbers

The portfolio site itself — excluding game assets — is approximately 2 MB total. That includes:

  • HTML files for all routes
  • CSS (Tailwind, purged)
  • JavaScript (React, R3F, Three.js, Lenis, GSAP)
  • Fonts (Bricolage Grotesque, Inter, Geist Mono)
  • A few images and the 3D models (.glb files)

On a decent connection, the first meaningful paint happens in under 1 second. The 3D hero takes a moment longer to initialise because Three.js needs to compile shaders, but the page content is visible and interactive immediately.

CloudFront edge caching means the static assets are served from a CDN node near the user. Gzip/Brotli compression is handled at the CDN level. Cache headers are set aggressively for immutable assets. The result is a portfolio that feels fast everywhere in the world.

The deploy pipeline

npm run build        # Outputs to ./out
aws s3 sync out/ s3://obstudio-bucket/ --delete
aws cloudfront create-invalidation --distribution-id XXXXX --paths "/*"

Three commands. Build, sync to S3, invalidate the CDN cache. The whole process takes about 90 seconds. No Docker. No Kubernetes. No CI/CD pipeline (though I should probably set one up).

Static export with Next.js is, in my experience, the sweet spot for content-heavy sites that want React's component model without the operational complexity of running a server. You get the developer experience of Next.js — file-based routing, TypeScript, the component ecosystem — with the simplicity of deploying a folder of files.

ob

Written by Oliver

Indie game developer building 3D web experiences in Godot and Next.js. I write about creative coding, procedural generation, and game feel.