Developer

Building a Next.js 16 Static Export App with PWA, SEO, and WebGL — What I Learned

Camera Simulator is a static Next.js app (App Router, output: 'export') that uses Three.js WebGL shaders, Zustand state management, Capacitor for mobile, and full SEO metadata. Here are the non-obvious things that caught me — mostly underdocumented edge cases in the static export path.

output: export breaks route handlers for robots.txt and sitemap.xml

The App Router's robots.ts and sitemap.ts file conventions generate /robots.txt and /sitemap.xml at runtime. With output: export, those routes are not written to the out/ directory. The files simply don't exist in the static build, so Googlebot gets a 404 and reports the site as blocked.

typescript
// ❌ This looks right but doesn't work with output: 'export'
// app/robots.ts
export default function robots() {
  return { rules: { userAgent: '*', allow: '/' } };
}

The fix is simple — put them directly in public/ as real files. They get copied verbatim into the export output:

text
# public/robots.txt
User-agent: *
Allow: /

Sitemap: https://yourdomain.com/sitemap.xml

Three.js must always be dynamically imported

Three.js touches window and document on import. Server-rendering it throws a ReferenceError. Every component that imports Three.js must be wrapped in dynamic() with ssr: false.

typescript
// ❌ breaks SSR build
import * as THREE from 'three';

// ✅ correct approach
const CameraPreview = dynamic(
  () => import('@/components/CameraPreview'),
  { ssr: false }
);

CanvasTexture needs needsUpdate = true every frame

This is not obvious from the Three.js docs. If you create a CanvasTexture from a canvas element that changes every frame, you must manually set needsUpdate = true in your animation loop — otherwise Three.js caches the first frame and the texture never updates.

typescript
const texture = new THREE.CanvasTexture(canvas);

// animation loop:
function animate() {
  requestAnimationFrame(animate);
  texture.needsUpdate = true; // ← required every frame
  renderer.render(scene, camera);
}

Zustand persist + static export hydration mismatch

If you use zustand/middleware/persist with localStorage, the server renders with default state but the client immediately rehydrates from localStorage — causing a flash and a React hydration warning. Fix it with skipHydration and a manual rehydrate call:

typescript
const useStore = create(
  persist(
    (set) => ({ iso: 400, aperture: 5.6 }),
    { name: 'camera-store', skipHydration: true }
  )
);

// In your root layout or top-level component:
useEffect(() => {
  useStore.persist.rehydrate();
}, []);

iOS safe area + viewport meta

For a full-screen camera UI that works on iPhones with a home indicator, you need both the viewport export and CSS env() variables:

typescript
// app/layout.tsx
export const viewport: Viewport = {
  width: 'device-width',
  initialScale: 1,
  maximumScale: 1,   // prevents double-tap zoom
  themeColor: '#0d0d0d',
};
css
.bottom-bar {
  padding-bottom: env(safe-area-inset-bottom, 0px);
}
Full project live at camerasimulator.online — built with Next.js 16, Three.js, Zustand, Tailwind, and Capacitor.
Try it yourself

Everything described in this article is visible in real time in the free Camera Simulator. No signup, no install — works in any browser.

Launch Simulator →

More articles