Files
TudorandClaude Opus 5 c2c76c5817 feat(cms): keep the admin panel out of the index
X-Robots-Tag rather than the robots.txt Disallow alone, for the same
reason the staging rule uses one: a Disallow blocks crawling, not
indexing, so a URL found from an external link can be indexed without
ever being fetched — and blocking the crawl means the noindex is never
seen. Both mechanisms are applied to /admin and /cms-api.

The existing CSP is frame-ancestors only, which restricts who may embed
the site rather than what a page may load, so it cannot break the panel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YmbBhr8s7GusjDE12hrZM
2026-09-02 16:17:55 +01:00

150 lines
5.2 KiB
JavaScript

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);