import { withPayload } from '@payloadcms/next/withPayload'; /** @type {import('next').NextConfig} */ const nextConfig = { // Enable standalone output for Docker output: 'standalone', // app/opengraph-image.tsx reads the Schibsted Grotesk files off disk at // request time (Satori needs a font buffer; it has no system fallback). // File tracing currently picks these up on its own, but that relies on the // tracer resolving a runtime join() — declare them so a Next upgrade can't // silently drop them and turn every link preview into a 500. outputFileTracingIncludes: { '/opengraph-image': ['./assets/**'], }, // The /api/* and /sitemap.xml proxies to the FastAPI backend are route // handlers (app/api/[...path]/route.ts, app/sitemap.xml/route.ts) rather // than rewrites, so the backend host is read from FASTAPI_URL at runtime // instead of being baked into the build. // Image optimization images: { remotePatterns: [ { protocol: 'https', hostname: '*.tile.openstreetmap.org' }, { protocol: 'https', hostname: 'tile.openstreetmap.org' }, { protocol: 'https', hostname: 'cdnjs.cloudflare.com' }, ], formats: ['image/avif', 'image/webp'], minimumCacheTTL: 31536000, }, // Performance optimizations compiler: { // Remove console logs in production removeConsole: process.env.NODE_ENV === 'production', }, // Compression compress: true, // React strict mode for better error detection reactStrictMode: true, // Power optimizations poweredByHeader: false, // Production source maps (disable for smaller bundles) productionBrowserSourceMaps: false, // Experimental features for performance experimental: { // Optimize package imports optimizePackageImports: ['chart.js', 'react-chartjs-2', 'leaflet'], }, // Headers for caching and security async headers() { return [ { /* * Keep non-production hosts out of the index. * * Staging serves the same image as production off stx., so without * this it is a full crawlable duplicate of the site. * * X-Robots-Tag, NOT a robots.txt Disallow. Disallow blocks crawling, * which is not the same as blocking indexing — a disallowed URL can * still be indexed from external links, and worse, blocking the crawl * means Google never fetches the page and never sees a noindex at all. * Staging therefore stays crawlable and answers "noindex" when crawled. * * Matched on the staging host explicitly rather than "any host that is * not production". The inverted form is tempting because it would cover * future environments automatically, but its failure mode is * deindexing production if the Host header ever arrives rewritten by a * proxy. This form's failure mode is a new environment being indexable * until someone adds it here — recoverable, where the other is not. * * Any new non-production hostname must be added to this list. */ source: '/:path*', has: [{ type: 'host', value: 'stx.schoolcompare.co.uk' }], headers: [ { key: 'X-Robots-Tag', value: 'noindex, nofollow', }, ], }, { /* * The admin panel and the CMS API must never be indexed. * * X-Robots-Tag, not just the robots.txt Disallow, for the same reason * the staging rule above uses one: a Disallow blocks crawling, which * is not indexing. A disallowed URL found from an external link can * still be indexed without ever being fetched — and worse, blocking * the crawl means the noindex is never seen. */ source: '/admin/:path*', headers: [{ key: 'X-Robots-Tag', value: 'noindex, nofollow' }], }, { source: '/cms-api/:path*', headers: [{ key: 'X-Robots-Tag', value: 'noindex, nofollow' }], }, { source: '/:path*', headers: [ { key: 'X-DNS-Prefetch-Control', value: 'on', }, { // Allow the analytics subdomain (Umami heatmap/recorder) to embed // the site while blocking all other origins. frame-ancestors is the // modern replacement for X-Frame-Options, which cannot allow a // specific sibling subdomain (ALLOW-FROM is deprecated/ignored). key: 'Content-Security-Policy', value: "frame-ancestors 'self' https://analytics.schoolcompare.co.uk", }, { key: 'X-Content-Type-Options', value: 'nosniff', }, { key: 'Referrer-Policy', value: 'origin-when-cross-origin', }, ], }, { // The mark is now the supplied raster artwork, so the favicon is // app/icon.png rather than an SVG. Pointing this at the old path was // caching a 404. source: '/icon.png', headers: [ { key: 'Cache-Control', value: 'public, max-age=31536000, immutable', }, ], }, ]; }, }; export default withPayload(nextConfig);