{"slug": "your-ai-built-next-js-16-landing-page-looks-fine-its-link-preview-might-not-6", "title": "Your AI-built Next.js 16 landing page looks fine. Its link preview might not: 6 metadata traps and the prompts that fix them", "summary": "A developer documented six metadata traps that AI-generated Next.js 16 landing pages commonly ship with, including a localhost:3000 fallback leaking into production robots.txt, sitemaps and og:image tags without failing the build. The fixes include throwing an error in next.config.ts when VERCEL_ENV is production and NEXT_PUBLIC_SITE_URL is empty or points at localhost, and redeploying after any NEXT_PUBLIC_ variable change since those values are inlined at build time and static routes like robots.txt and sitemap.xml stay cached.", "body_md": "An AI-built Next.js 16 landing page can look perfect in your browser while its link previews, `robots.txt`, and sitemap still point at `localhost:3000`, lose their image on every subpage, or show up as a blank card. Here are six metadata traps, each checked on a real build, with a fix and a prompt you can paste into Cursor, Claude Code, or ChatGPT.\n\nThe examples come from a waitlist landing page built prompt by prompt on `create-next-app` 16.4 (Next.js 16.4, React 19.3, Tailwind v4). None of these show up when you click around on `localhost`, which is why they slip through. You only notice when someone shares the link.\n\n`localhost:3000` ends up in production\nAsk an assistant for a site config and you'll almost always get this line. Our own prompt asked for exactly this:\n\n```\nurl: process.env.NEXT_PUBLIC_SITE_URL ?? \"http://localhost:3000\",\n```\n\nThat fallback is fine for local dev. The problem is that nothing complains when the variable is missing in production. We built the app without it and checked the output. `robots.txt` said `Sitemap: http://localhost:3000/sitemap.xml`, every `<loc>` in the sitemap was `http://localhost:3000`, and the static admin page had an `og:image` on localhost. The build passed without a single error.\n\nIf the assistant skips `metadataBase` entirely, Next.js does warn you, but only once, buried in the build log:\n\n```\n⚠ metadataBase property in metadata export is not set for resolving social open graph or twitter images, using \"http://localhost:3000\".\n```\n\nAlso remember that `.env.local` never reaches Vercel. The default `.gitignore` from `create-next-app` ignores `.env*`, so the variable has to be set in the project settings.\n\nThe fix is to make a missing URL break the build where it matters. This goes at the top of `next.config.ts`:\n\n``` js\nconst siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? \"\";\nif (process.env.VERCEL_ENV === \"production\" && (!siteUrl || siteUrl.includes(\"localhost\"))) {\n  throw new Error(`NEXT_PUBLIC_SITE_URL is \"${siteUrl}\". Set it to your real domain for Production and redeploy.`);\n}\n```\n\n`VERCEL_ENV` is a Vercel system variable that's available at build time, so local builds and preview deploys are left alone. In our test, faking `VERCEL_ENV=production` with an empty URL or a localhost URL failed the build. A real domain passed.\n\nIn `next.config.ts`, fail the build when `VERCEL_ENV` is `\"production\"` and `NEXT_PUBLIC_SITE_URL` is empty or contains `localhost`. Keep the localhost fallback in `lib/site.ts` for dev. Add `NEXT_PUBLIC_SITE_URL` to `.env.example` with a comment saying it must be set in Vercel's Production environment.\n\nThe natural reaction is to add the variable in the Vercel dashboard and refresh. That doesn't work, for two reasons.\n\nFirst, `NEXT_PUBLIC_` variables are inlined when you run `next build`. The Next.js docs say they're \"frozen with the value evaluated at build time.\" We built with `https://a.example` and started the server with `https://b.example`. The page and `robots.txt` both still said `a.example`.\n\nSecond, a lot of your SEO output is static. In our test runs, `npm run build` marked the OG image, `robots.txt`, `sitemap.xml`, and the icon as static routes. The docs call `robots.js` and `sitemap.js` special Route Handlers that are \"cached by default.\" When we built without the variable and only set it at `next start`, the dynamic home page picked it up, but `robots.txt`, the sitemap, and the static admin page stayed on localhost.\n\nVercel's docs are explicit too: environment variable changes \"only apply to new deployments. You must redeploy.\" And a variable you add only for Preview won't exist in Production.\n\nIf you'd rather not maintain a variable at all, Vercel sets `VERCEL_PROJECT_PRODUCTION_URL` on every deployment, previews included, and its docs suggest it for OG image URLs. It has no `https://`, and since it doesn't start with `NEXT_PUBLIC_` it won't reach client components, so only use it in server code.\n\nAfter changing any `NEXT_PUBLIC_` variable or anything used by `robots.ts`, `sitemap.ts`, or `opengraph-image.tsx`, tell me to redeploy, not just refresh. Add a line about this to the README's deploy notes.\n\n`openGraph` wipes out the layout's\nThis one is easy to miss. The root layout sets a full `openGraph` object, and then the assistant gives the pricing page its own share title:\n\n``` js\nexport const metadata: Metadata = { title: \"Pricing\", openGraph: { title: \"Pricing\" } };\n```\n\nNext.js merges metadata shallowly, so a page that defines `openGraph` replaces the parent's whole `openGraph` object. On our build, that page lost `og:url`, `og:site_name`, `og:type`, `og:image`, and `twitter:image`. The root `opengraph-image.tsx` still existed, but the page no longer pointed at it. Share that link and you get a card with no image.\n\nThe docs suggest pulling the shared fields into a variable and spreading it. Include the image, or you'll get everything back except the part people actually see:\n\n``` js\n// lib/seo.ts\nimport { site } from \"@/lib/site\";\nexport const sharedOpenGraph = {\n  siteName: site.name,\n  type: \"website\" as const,\n  images: [{ url: \"/opengraph-image\", width: 1200, height: 630, alt: site.name }],\n};\n\n// app/pricing/page.tsx\nexport const metadata: Metadata = {\n  title: \"Pricing\",\n  openGraph: { ...sharedOpenGraph, title: \"Pricing\", url: \"/pricing\" },\n};\n```\n\nWith that, the page had `og:image` and `twitter:image` again. The opposite case is quieter. A page that defines no `openGraph` inherits everything, including the layout's `og:url`. Our status page announced the homepage as its URL.\n\nCreate `lib/seo.ts` exporting `sharedOpenGraph` (siteName, type, and the OG image as `/opengraph-image`, 1200×630). Every page that sets `openGraph` must spread it first and set its own `title` and `url`. Never set a partial `openGraph` object on a page.\n\n`opengraph-image.tsx` with `ImageResponse` looks like normal JSX, so assistants write it like a normal component. It isn't one. The docs say: \"Only flexbox and a subset of CSS properties are supported. Advanced layouts (e.g. `display: grid`) will not work.\" There's also a 500 KB bundle limit (fonts and images count), and only `ttf`, `otf`, and `woff` fonts.\n\nWe tried two failure modes. A `<div>` with three children and no `display: \"flex\"` breaks the build, which at least tells you:\n\n```\nError: Expected <div> to have explicit \"display: flex\", \"display: contents\", or \"display: none\" if it has more than one child node.\n```\n\nTailwind classes are worse, because nothing complains. With `className=\"flex h-full w-full bg-violet-700 text-white text-6xl\"` the build passed and the route returned a valid 1200×630 PNG with a transparent background and tiny black text in the top-left corner.\n\nIn `app/opengraph-image.tsx`, use only inline `style` objects, no `className`. Every `div` with more than one child gets `display: \"flex\"`. No grid. If you load a custom font, use a `.ttf` or `.otf` file. After building, open `/opengraph-image` and describe what you see.\n\n`themeColor` in `metadata` is ignored\nOlder tutorials put `themeColor` and `viewport` inside `metadata`, and assistants still copy that. Both have been deprecated there since Next.js 14. On 16.4 the build passes and prints a warning for each prerendered route:\n\n```\n⚠ Unsupported metadata themeColor is configured in metadata export in /admin. Please move it to viewport export instead.\n```\n\nThe HTML had no `theme-color` tag at all. Moving it fixed it:\n\n``` python\nimport type { Metadata, Viewport } from \"next\";\nexport const viewport: Viewport = { themeColor: \"#7c3aed\" };\n```\n\nAfter that the page had `<meta name=\"theme-color\" content=\"#7c3aed\"/>`. You don't need a `viewport` string either, since Next.js adds `width=device-width, initial-scale=1` by default.\n\nA related one, and this time the build tells you: if the assistant puts `\"use client\"` on a page that exports `metadata`, the build fails with \"You are attempting to export \"metadata\" from a component marked with \"use client\"\". Don't let it \"fix\" that by deleting the metadata. Keep the page a Server Component and move the interactive part into its own client component.\n\nNever put `themeColor`, `colorScheme`, or `viewport` in `metadata`; use `export const viewport: Viewport`. Pages that export `metadata` stay Server Components. Move hooks and handlers into a separate `\"use client\"` file.\n\n`robots.txt` and `noindex` cancel each other out\nA careful assistant will both disallow `/admin` in `robots.ts` and set `robots: { index: false }` on the admin page. The test app built from our own pack does exactly that. It sounds like belt and braces, but Google's docs say: \"For the noindex rule to be effective, the page or resource must not be blocked by a robots.txt file.\" If the crawler can't fetch the page, it never sees the `noindex`, and the URL can still be listed if something links to it.\n\nPick one per path. For pages you want out of search results, leave them crawlable and use `noindex`. Use `Disallow` for things like `/api/` that you just don't want crawled. And neither one is access control. `robots.txt` is a public file that lists your admin path for anyone who reads it, so the password check is what actually protects it.\n\nPreviews mostly take care of themselves. Vercel adds `X-Robots-Tag: noindex` to preview deployments and to outdated production deployments, except when a custom domain is assigned to a non-production branch. A `staging.yourdomain.com` branch domain can be indexed unless you add the header yourself.\n\nIn `robots.ts`, disallow only `/api/`. Keep `robots: { index: false }` on `/admin` and `/status`. Add a comment that robots rules aren't security, and that the admin route must check the password on the server.\n\nRun this against the production URL after every deploy that touches metadata:\n\n```\nURL=https://your-domain.com\nfor p in / /status; do\n  echo \"== $p\"\n  curl -s \"$URL$p\" | grep -oE '<meta (property|name)=\"(og:url|og:image|twitter:card|twitter:image)\" content=\"[^\"]*\"'\ndone\ncurl -s \"$URL/robots.txt\" | grep -i sitemap\ncurl -s \"$URL/sitemap.xml\" | grep -c localhost   # should print 0\ncurl -sI \"$URL/opengraph-image\" | grep -i '^content-type'   # image/png\n```\n\nEvery page should print an `og:image` on your real domain, the sitemap line should not say localhost, and the image route should return `image/png`. Then open `/opengraph-image` in a browser and look at it, because a transparent card still returns `image/png`.\n\nIt also helps to put the rules in `AGENTS.md` or `.cursor/rules/seo.mdc` (with `alwaysApply: true`), so the assistant still follows them twenty prompts later:\n\n```\nSEO rules:\n- Site URL comes from NEXT_PUBLIC_SITE_URL; the Vercel production build fails without it.\n- Pages that set openGraph spread sharedOpenGraph from lib/seo.ts.\n- opengraph-image.tsx: inline styles only, display:flex on multi-child divs.\n- themeColor lives in the viewport export, never in metadata.\n- noindex pages are not disallowed in robots.ts.\n```\n\nIf you're starting a landing page from scratch, the [free Landing Page + Waitlist sample](https://shipinanhour.gumroad.com/l/qfzgeg) has four prompts from our pack: a scaffold that won't break `npm run build`, a Tailwind v4 dark-mode toggle, and rescues for build failures and invisible colors. The metadata prompt isn't in it, so add the prompts above yourself.", "url": "https://wpnews.pro/news/your-ai-built-next-js-16-landing-page-looks-fine-its-link-preview-might-not-6", "canonical_source": "https://dev.to/shipinanhour/your-ai-built-nextjs-16-landing-page-looks-fine-its-link-preview-might-not-6-metadata-traps-and-35lf", "published_at": "2026-11-03 13:18:26+00:00", "updated_at": "2026-11-03 13:24:16.814298+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools", "generative-engine-optimization"], "entities": ["Next.js", "Vercel", "React", "Tailwind CSS", "Cursor", "Claude Code", "ChatGPT"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/your-ai-built-next-js-16-landing-page-looks-fine-its-link-preview-might-not-6", "markdown": "https://wpnews.pro/news/your-ai-built-next-js-16-landing-page-looks-fine-its-link-preview-might-not-6.md", "text": "https://wpnews.pro/news/your-ai-built-next-js-16-landing-page-looks-fine-its-link-preview-might-not-6.txt", "jsonld": "https://wpnews.pro/news/your-ai-built-next-js-16-landing-page-looks-fine-its-link-preview-might-not-6.jsonld"}}