Monorepo OG Tags: Add Open Graph to a Turborepo or Nx Workspace
How to manage og:title, og:image, og:description and Twitter card tags across multiple apps in a Turborepo or Nx monorepo.
The monorepo OG challenge
In a Turborepo or Nx monorepo, you typically have multiple apps (marketing site, dashboard, docs, blog) that each need their own OG tags. The challenge is keeping OG tag logic consistent across apps while allowing each app to customize its metadata.
The solution: create a shared OG package in your monorepo's packages/ directory that exports reusable metadata utilities, then import them in each app.
Shared OG package setup
# Monorepo structure ├── apps/ │ ├── web/ # Marketing site (Next.js) │ ├── docs/ # Documentation (Next.js) │ └── dashboard/ # App dashboard (Next.js) ├── packages/ │ ├── og-utils/ # Shared OG metadata utilities │ │ ├── src/ │ │ │ └── index.ts │ │ ├── package.json │ │ └── tsconfig.json │ └── ui/ # Shared UI components ├── turbo.json └── package.json
Building the shared OG utility
// packages/og-utils/src/index.ts
import type { Metadata } from "next";
interface OgConfig {
siteName: string;
siteUrl: string;
defaultImage: string;
twitterHandle?: string;
}
interface PageOg {
title: string;
description: string;
path: string;
image?: string;
type?: "website" | "article";
}
export function createOgMetadata(config: OgConfig) {
return function buildMetadata(page: PageOg): Metadata {
const url = `${config.siteUrl}${page.path}`;
const image = page.image ?? config.defaultImage;
return {
title: page.title,
description: page.description,
metadataBase: new URL(config.siteUrl),
alternates: { canonical: page.path },
openGraph: {
title: page.title,
description: page.description,
type: page.type ?? "website",
url,
siteName: config.siteName,
images: [{ url: image, width: 1200, height: 630 }],
},
twitter: {
card: "summary_large_image",
title: page.title,
description: page.description,
images: [image],
...(config.twitterHandle && {
creator: config.twitterHandle,
}),
},
};
};
}
// packages/og-utils/package.json
// {
// "name": "@acme/og-utils",
// "version": "0.0.1",
// "main": "./src/index.ts",
// "types": "./src/index.ts",
// "dependencies": {},
// "peerDependencies": {
// "next": ">=14"
// }
// }Using the shared package in each app
// apps/web/src/lib/og.ts
import { createOgMetadata } from "@acme/og-utils";
export const buildMetadata = createOgMetadata({
siteName: "Acme — Marketing",
siteUrl: "https://acme.com",
defaultImage: "https://acme.com/og/default.png",
twitterHandle: "@acme",
});
// apps/web/src/app/pricing/page.tsx
import { buildMetadata } from "@/lib/og";
export const metadata = buildMetadata({
title: "Pricing — Acme",
description: "Simple, transparent pricing for teams of all sizes.",
path: "/pricing",
});
export default function PricingPage() {
return <main>...</main>;
}
// apps/docs/src/lib/og.ts
import { createOgMetadata } from "@acme/og-utils";
export const buildMetadata = createOgMetadata({
siteName: "Acme Docs",
siteUrl: "https://docs.acme.com",
defaultImage: "https://docs.acme.com/og/default.png",
});
// apps/docs/src/app/guides/[slug]/page.tsx
import { buildMetadata } from "@/lib/og";
export async function generateMetadata({ params }) {
const guide = await getGuide(params.slug);
return buildMetadata({
title: guide.title,
description: guide.summary,
path: `/guides/${guide.slug}`,
type: "article",
});
}Shared OG image generation endpoint
Create a shared OG image API that all apps can reference:
// apps/web/src/app/api/og/route.tsx (or a shared edge function)
import { ImageResponse } from "next/og";
export const runtime = "edge";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const title = searchParams.get("title") ?? "Acme";
const theme = searchParams.get("theme") ?? "dark";
return new ImageResponse(
(
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
width: 1200,
height: 630,
background: theme === "dark" ? "#0a0a0a" : "#ffffff",
color: theme === "dark" ? "#ffffff" : "#0a0a0a",
padding: 60,
}}
>
<p style={{ fontSize: 24, color: "#a855f7", margin: 0 }}>acme.com</p>
<h1 style={{ fontSize: 56, margin: "20px 0 0" }}>{title}</h1>
</div>
),
{ width: 1200, height: 630 }
);
}
// Reference from any app:
// image: "https://acme.com/api/og?title=My+Page&theme=dark"Nx workspace: same pattern
In an Nx monorepo, the structure is similar. Create a library instead of a package:
# Generate a new Nx library for OG utilities:
npx nx generate @nx/js:library og-utils --directory=libs/og-utils
# libs/og-utils/src/index.ts
# Same createOgMetadata() function as above
# Import in apps:
import { createOgMetadata } from "@acme/og-utils";
# Nx project.json ensures the lib is built before apps that depend on it.Verify your monorepo OG tags
After deploying each app, paste its URLs into OGFixer to preview how Twitter, Slack, Discord, and LinkedIn will render your links across all apps in your monorepo.