MotirBuilding in public
MOTIR · moooon
onMotir
You’re viewing a public project. Anyone can view it — no account needed. Sign in to submit, upvote, or comment on requests.View-only — you can’t edit work items
MOTIR-2505

`metadataBase` is unset, so every OG/Twitter image URL on the public surface resolves against `http://localhost:8080`

Done
Description

Repo: motir-core. Found 2026-08-09 in production logs, during MOTIR-2392's cutover.

The observation

Emitted by the running app on Fly, on every render of a page carrying metadata:

⚠ metadataBase property in metadata export is not set for resolving social open graph
  or twitter images, using "http://localhost:8080"

Next resolves relative openGraph.images / twitter.images URLs against metadataBase. With it unset, Next falls back to the dev origin — so the absolute URL emitted into <meta property="og:image"> points at http://localhost:8080, which no crawler, social card renderer or link-preview fetcher can reach.

Confirmed in the code, not inferred from the warning

$ grep -rn "metadataBase" app/ lib/ next.config.ts
(no matches — set nowhere in the repository)

app/layout.tsx:96-99 is the whole root metadata export:

export const metadata: Metadata = {
  title: 'Motir',
  description: 'AI-native project management — open-source PM substrate.',
};

What it costs

It undermines work already shipped. MOTIR-1150 built two OG image routes to the brand spec — app/(public)/explore/opengraph-image.tsx and app/(public)/p/[identifier]/opengraph-image.tsx, each rendering the real wave-band mark through ImageResponse. Those images generate correctly and are then advertised at an address nobody outside the container can fetch.

The affected surface is the whole crawlable public set built by Stories 6.12/6.13: /explore, its rank variants, /explore/topic/<slug>, and /p/<identifier> plus its board/items/roadmap tabs. Every one of them is meant to be shared and indexed.

This is PRE-EXISTING and was not caused by the hosting move — it would have been equally wrong on Vercel. Two things make it worth carding now: the public surface is live on a new origin, and MOTIR-1130 ("Take Motir's project public + smoke the public surfaces") is going to walk exactly these pages.

The fix — and the two traps in it

Set metadataBase from the origin accessor the app already owns:

import { resolveBaseUrlTrimmed } from '@/lib/baseUrl';

metadataBase: new URL(resolveBaseUrlTrimmed()),

⚠️ TRAP 1 — import ONLY from lib/baseUrl.ts

app/layout.tsx sits in every route's module graph, so anything it imports is traced into every server function Next builds. That file's own docstring records the measurement (MOTIR-2381): 340 of 348 traced functions already carry @prisma/client, and lib/db.ts instantiates a client at module scope.

lib/baseUrl.ts has zero imports — a pure environment reader — so it is safe. lib/publicProjects/urls.ts's publicSiteOrigin() is a one-line delegate to the same function and is also light today, but it is a domain module free to grow imports later. Import the leaf.

⚠️ TRAP 2 — a static metadata export can BAKE the localhost fallback

export const metadata is evaluated when the module is loaded, which for statically-rendered routes means at build time — and the image build deliberately runs without MOTIR_BASE_URL (MOTIR-2490 sets only inert placeholders), so resolveBaseUrlTrimmed() would return the localhost fallback and Next would freeze it into the output. The build would look clean and ship the same bug.

The build generates 177 static pages, so this is not hypothetical. Prefer export async function generateMetadata(), which is evaluated per request with the real runtime environment — or, if the static export is kept, prove by fetching a built page that the baked value is the production origin and not localhost.

Acceptance criteria

  • metadataBase is set, sourced from lib/baseUrl.ts, and not hardcoded.
  • Verified against a real response, not the source: fetch a public page in production and assert <meta property="og:image"> is absolute against the production origin, with no occurrence of localhost in any og: or twitter: URL.
  • The ⚠ metadataBase … is not set warning no longer appears in the Fly logs.
  • The root layout's import graph is not widened — no new transitive @prisma/client reach. scripts/measure-prisma-traces.mjs is the existing instrument; the traced-function count must not rise.
  • Covered by a test, so the value cannot silently revert to the fallback — the failure mode here is a warning in a log nobody reads, which is how it survived this long.
  • Both OG routes' images still render (the brand spec from MOTIR-1150 is unchanged by this card).

Scope BOUNDARY

The metadata base URL only. It does not redesign the OG images, change the brand mark, touch sitemap.ts or robots, or alter canonical-URL logic — publicProjectUrl() already builds absolute canonicals correctly through the same accessor, which is why canonicals were never affected and only the image URLs are wrong.

Context refs

  • app/layout.tsx:96-99 — the metadata export to amend, and its docstring on why the import graph is load-bearing.
  • lib/baseUrl.tsresolveBaseUrl() / resolveBaseUrlTrimmed(), the zero-import leaf to use.
  • lib/publicProjects/urls.ts:15publicSiteOrigin(), the sibling accessor, and why canonical URLs are already right.
  • app/(public)/explore/opengraph-image.tsx · app/(public)/p/[identifier]/opengraph-image.tsx — the images being mis-addressed.
  • MOTIR-1150 — shipped those two routes.
  • MOTIR-2381 — the trace measurement behind trap 1.
  • MOTIR-2490 — the build-time environment behind trap 2.
  • MOTIR-1130 — the public-surface smoke this would otherwise fail.