diff --git a/packages/sanity-dataset-resolve/src/index.ts b/packages/sanity-dataset-resolve/src/index.ts index 1ef644f7..b66f60df 100644 --- a/packages/sanity-dataset-resolve/src/index.ts +++ b/packages/sanity-dataset-resolve/src/index.ts @@ -50,7 +50,8 @@ function isProductionHost(env: SanityDatasetResolveInput): boolean { * Whether this deploy is the production site: an explicit `SANITY_STUDIO_DEPLOYMENT_TARGET` * decides (`production` or not, any case), otherwise the host's production context. A staging * site on a production host context sets the target to stay non-production. The web's crawl - * switch (`robots.ts`) and canonical origin (`siteUrl.ts`) follow it. + * switch (`robots.ts`) follows it, and so does its canonical origin (`siteUrl.ts`) except on + * Netlify's production context, which serves the primary domain either way. */ export function isProductionDeployment( env: SanityDatasetResolveInput, diff --git a/web/next.config.ts b/web/next.config.ts index 7dfc36b9..68889825 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -1,6 +1,8 @@ import { resolveStudioDatasetAsync } from "@repo/sanity-dataset-resolve"; import type { NextConfig } from "next"; +import { SITE_BASE_URL } from "./src/utils/siteUrl"; + const isDev = process.env.NODE_ENV === "development"; function originOf(raw: string | undefined): string | null { @@ -228,6 +230,10 @@ export default async function config(): Promise { return { ...nextConfig, env: { + /* Resolved here, where Netlify's `URL` / `DEPLOY_PRIME_URL` exist, and + * inlined: the function runtime lacks them, so ISR regenerations and + * route handlers fell back to `http://localhost:3000`. */ + NEXT_PUBLIC_SITE_URL: SITE_BASE_URL, SANITY_STUDIO_PROJECT_ID: process.env.SANITY_STUDIO_PROJECT_ID ?? "", SANITY_STUDIO_DATASET: await resolveStudioDatasetAsync(process.env), }, diff --git a/web/src/utils/siteUrl.ts b/web/src/utils/siteUrl.ts index 3ac56432..13b17221 100644 --- a/web/src/utils/siteUrl.ts +++ b/web/src/utils/siteUrl.ts @@ -3,9 +3,12 @@ import { isProductionDeployment } from "@repo/sanity-dataset-resolve"; /** * The site's canonical origin, resolved once at module load. * - * Server-only. Everything below `NEXT_PUBLIC_SITE_URL` reads host variables - * that Next never inlines into the client bundle, so import this from - * metadata, `robots.ts` and `sitemap.ts` — not from a client component. + * Resolved at build: `next.config.ts` imports it while the host's build + * variables exist and inlines the result as `NEXT_PUBLIC_SITE_URL`. Netlify's + * function runtime, which serves ISR regenerations and route handlers, has no + * `URL` / `DEPLOY_PRIME_URL`, so it reads that inlined value. Server-only all + * the same: import this from metadata, `robots.ts` and `sitemap.ts` — not + * from a client component. * * Canonicals, `hreflang`, `og:url`, `metadataBase` and the sitemap reference * in `robots.txt` all build on it. An explicit value wins, the deploy host's @@ -27,15 +30,17 @@ function normalizeOrigin(value: string | undefined): string | null { const isProduction = isProductionDeployment(process.env); /** - * Netlify's own view of where this build is served from. Previews, branch - * deploys and non-production targets resolve to their own origin - * (`DEPLOY_PRIME_URL`) rather than the production domain, so they never claim - * to be canonical for the live site. The `NETLIFY` guard keeps a stray local - * `URL` variable out. + * Netlify's own view of where this build is served from. Previews and branch + * deploys resolve to their own origin (`DEPLOY_PRIME_URL`) rather than the + * production domain, so they never claim to be canonical for the live site. + * The `NETLIFY` guard keeps a stray local `URL` variable out. */ function netlifyOrigin(): string | null { if (process.env.NETLIFY !== "true") return null; - if (isProduction) { + /* The production context serves the primary domain even when the deploy + * target keeps it non-production (a staging site on the development + * dataset): `DEPLOY_PRIME_URL` would be the `--` host there. */ + if (isProduction || process.env.CONTEXT === "production") { return normalizeOrigin(process.env.URL); } return ( @@ -54,10 +59,11 @@ function vercelOrigin(): string | null { } function resolveSiteBaseUrl(): string { - /* Explicit wins, and it is the only candidate that is inlined at build - * time — so the prerender and every later ISR regeneration agree on it. - * Set it (netlify.toml `[context.production.environment]`) on a site with - * its own domain; don't rely on the rest. */ + /* Explicit wins. Inside the app it is always set: `next.config.ts` inlines + * this resolver's build-time result under that name, so the prerender and + * every later ISR regeneration agree on it. Set it yourself (netlify.toml + * `[context.production.environment]`) on a site with its own domain, so the + * canonical never depends on which domain the host considers primary. */ const explicit = normalizeOrigin(process.env.NEXT_PUBLIC_SITE_URL); if (explicit) return explicit;