
Most link preview advice assumes one site, one brand and one domain. A multi-tenant app breaks all three assumptions. The same Next.js codebase serves a bakery on its own domain, a tutor on a platform subdomain and a clinic that hasn't connected its domain yet, and every one of them expects their links to look like their business when shared.
I've built a few of these for clients, and the link preview is one of the places where tenant isolation quietly leaks. A tenant's page shares with the platform's name in the card. The image URL points at the platform domain. One tenant's brand colour makes the share image unreadable. None of these break the page, so nobody notices until a customer forwards a link.
This post covers the setup I use now: tenant-aware metadata, a per-tenant share image, and the handful of mistakes worth checking for.
The routing shape
I use the common pattern of rewriting every request to a dynamic [domain] segment based on the Host header. In Next.js 15 this lives in middleware.ts. Next.js 16 renamed the file to proxy.ts, with the same idea.
// middleware.ts (proxy.ts in Next.js 16)
import { NextResponse, type NextRequest } from "next/server";
import { primaryDomainFor } from "@/lib/tenants/edge";
export async function middleware(req: NextRequest) {
const host = (req.headers.get("host") ?? "").split(":")[0].toLowerCase();
// One canonical host per tenant: send secondary hosts to the primary one.
const primary = await primaryDomainFor(host);
if (primary && primary !== host) {
const url = new URL(req.nextUrl.pathname + req.nextUrl.search, `https://${primary}`);
return NextResponse.redirect(url, 308);
}
const url = req.nextUrl.clone();
url.pathname = `/${host}${req.nextUrl.pathname}`;
return NextResponse.rewrite(url);
}
export const config = {
matcher: ["/((?!api|_next|favicon.ico|robots.txt|sitemap.xml).*)"],
};
The redirect block matters more for previews than it looks. If a tenant is reachable at example-bakery.platform.app and example-bakery.com, people will share both. Two hosts means two separate cache entries on every app that stores cards, and two chances for them to look different. One canonical host, with a permanent redirect from the others, keeps it to one.
Tenant data the preview needs
Whatever your tenant table looks like, the preview needs a few fields. I keep them together:
// lib/tenants/types.ts
export type Tenant = {
slug: string; // "example-bakery"
name: string; // shown as og:site_name
primaryDomain: string; // "example-bakery.com" or "example-bakery.platform.app"
tagline?: string; // fallback description
brandColor: string; // "#f4d35e"
logoUrl?: string;
defaultShareImage?: string; // uploaded by the tenant, absolute URL
};
tagline and defaultShareImage are optional because most tenants won't fill them in on day one. The fallback rules for those empty fields are where the real design work is.
Metadata in the tenant layout
The layout for app/[domain] sets everything that is true for every page of that tenant:
// app/[domain]/layout.tsx
import type { Metadata } from "next";
import { getTenantByHost } from "@/lib/tenants";
type Props = { params: Promise<{ domain: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const tenant = await getTenantByHost(decodeURIComponent((await params).domain));
if (!tenant) return { robots: { index: false } };
return {
metadataBase: new URL(`https://${tenant.primaryDomain}`),
title: { default: tenant.name, template: `%s | ${tenant.name}` },
description: tenant.tagline ?? `${tenant.name}: menu, prices and how to order.`,
openGraph: tenantOpenGraph(tenant),
};
}
metadataBase is the single most important line. Relative URLs in metadata, like alternates.canonical: "/menu", get resolved against it. Set it from the tenant's primary domain and every canonical and og:url lands on the right host. Forget it, or set it once from an environment variable, and every tenant's cards point at the platform.
The fallback description is written for the tenant's customers, not for the platform's marketing. A tenant page that shares with "Build your online store in minutes" under the tenant's name is the most common leak I see, and it reads like an ad for someone else.
The shallow merge trap
Here's the mistake that took me longest to spot. Next.js merges metadata from layouts and pages shallowly. If the layout sets openGraph.siteName and a page returns its own openGraph object with a title and image, the page's object replaces the layout's entirely. The siteName from the layout is gone.
So I never write openGraph by hand in pages. Both the layout and every page go through one helper:
// lib/tenants/og.ts
import type { Metadata } from "next";
import type { Tenant } from "./types";
type OG = NonNullable<Metadata["openGraph"]>;
export function shareImageUrl(tenant: Tenant, postId?: string) {
if (!postId && tenant.defaultShareImage) return tenant.defaultShareImage;
const qs = new URLSearchParams({ t: tenant.slug, ...(postId && { post: postId }) });
return `https://${tenant.primaryDomain}/api/og?${qs}`;
}
export function tenantOpenGraph(tenant: Tenant, page: Partial<OG> & { postId?: string } = {}): OG {
const { postId, ...rest } = page;
return {
type: "website",
siteName: tenant.name,
images: [{ url: shareImageUrl(tenant, postId), width: 1200, height: 630, alt: tenant.name }],
...rest,
};
}
A page then only says what's different:
// app/[domain]/menu/[item]/page.tsx (excerpt)
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { domain, item } = await params;
const tenant = await getTenantByHost(decodeURIComponent(domain));
const product = tenant && (await getProduct(tenant.slug, item));
if (!tenant || !product) return {};
return {
title: product.name,
description: product.summary,
alternates: { canonical: `/menu/${product.slug}` },
openGraph: tenantOpenGraph(tenant, {
title: product.name,
description: product.summary,
url: `/menu/${product.slug}`,
postId: product.id,
}),
};
}
A per-tenant share image
Tenants who upload their own share image get it everywhere. Everyone else gets a generated one in their brand colour. I serve it from a route handler, outside the [domain] rewrite, with the tenant in the query string:
// app/api/og/route.tsx
import { ImageResponse } from "next/og";
import { getTenantBySlug, getProductById } from "@/lib/tenants";
function textColorFor(hex: string) {
const n = parseInt(hex.replace("#", ""), 16);
const [r, g, b] = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((v) => {
const c = v / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
return luminance > 0.4 ? "#111111" : "#ffffff";
}
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const tenant = await getTenantBySlug(searchParams.get("t") ?? "");
if (!tenant) return new Response("Not found", { status: 404 });
const postId = searchParams.get("post");
const product = postId ? await getProductById(tenant.slug, postId) : null;
const title = product?.name ?? tenant.tagline ?? tenant.name;
const fg = textColorFor(tenant.brandColor);
return new ImageResponse(
(
<div style={{ display: "flex", flexDirection: "column", justifyContent: "space-between",
width: "100%", height: "100%", padding: 72, background: tenant.brandColor, color: fg }}>
<div style={{ fontSize: 34, opacity: 0.85 }}>{tenant.name}</div>
<div style={{ fontSize: 68, fontWeight: 700, lineHeight: 1.1 }}>{title.slice(0, 90)}</div>
<div style={{ fontSize: 28, opacity: 0.75 }}>{tenant.primaryDomain}</div>
</div>
),
{ width: 1200, height: 630,
headers: { "Cache-Control": "public, max-age=86400, s-maxage=86400" } },
);
}
Three decisions in there are deliberate:
- The image takes IDs, not text. It looks up the product by
postID instead of accepting?title=.... An image endpoint that renders any text you pass it, in a real business's colours, under their name, is an invitation for someone to make fake announcements that look official. - The tenant is in the URL, not read from the
Hostheader. The response then depends only on its URL, so any cache in front of it can't hand one tenant's image to another. - Text colour is computed. Tenants pick brand colours for their logo, not for legibility. The luminance check picks dark or light text so a pale yellow brand doesn't end up with white text on it. The 0.4 threshold is a rough rule of thumb. Adjust it after looking at your actual palette.
I generate the image from a route handler rather than an opengraph-image.tsx file inside app/[domain], because with rewrites I found it too easy to end up with an emitted image URL that includes the internal [domain] path. If you use the file convention, check the og:image URL in the rendered HTML for a real tenant.
Tenants with unfinished setups
Two states cause most of the broken cards I've debugged:
- Custom domain added, DNS or certificate not ready. The tenant starts sharing the new domain the moment they've typed it in. Until HTTPS works, crawlers fail to fetch the page, and some apps may keep that failed result for a while. I keep
primaryDomainon the platform subdomain until the custom domain verifies, so canonicals and redirects only switch once the new host actually serves. - Tenant suspended or deleted. The layout above returns
robots: { index: false }with no Open Graph for unknown hosts. A suspended tenant should get a plain, honest page, not a card that still advertises their business with the platform's branding around it.
Checking it per tenant
Unit tests can cover the helpers, but the real question is what one specific tenant's live page sends. When a tenant goes live, or a support ticket says "my link looks wrong", I paste their URL into OG Scope's LinkedIn preview. It reads the public HTML, lists og:site_name and the other tags it found, and draws an estimated card. A platform name in the site name field, or an image URL on the wrong host, shows up immediately. It's an estimate rather than LinkedIn's own renderer, so for cache problems you still need the platform's own tools, but it answers the "is it our HTML?" question in seconds.
Checklist
- One primary domain per tenant, other hosts 308 to it.
metadataBasefrom the tenant's primary domain, set in the tenant layout.openGraphalways built by a helper, because of shallow merging.- Fallback copy written for the tenant's customers.
- Share image endpoint takes IDs, keeps the tenant in the URL, and picks readable text.
- Primary domain switches only after the custom domain verifies.
I'm Abid Ali, a freelance full stack developer in Karachi working with TypeScript, Next.js, React and Node.js. I built OG Scope, a free link preview checker.
About the author
- I'm Abid Ali, a full stack developer from Karachi with 3+ years of experience in TypeScript, Next.js, Gatsby, React and React Native. I build web apps, mobile apps and interactive UIs with solid API integration. Side projects: OG Scope (ogscope.pk), a free Open Graph checker; Digital Dialogue (digitaldialogue.pk), freelancing guides for Upwork and Fiverr beginners; and Meme Tap (memetap.pk), a free browser meme soundboard.
Comments
Join the discussionNo comments yet. Be the first to comment.


