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.
// ❌ 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:
# public/robots.txt
User-agent: *
Allow: /
Sitemap: https://yourdomain.com/sitemap.xmlThree.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.
// ❌ 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.
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:
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:
// app/layout.tsx
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1, // prevents double-tap zoom
themeColor: '#0d0d0d',
};.bottom-bar {
padding-bottom: env(safe-area-inset-bottom, 0px);
}