diff --git a/examples/app-router/app/api/og/route.tsx b/examples/app-router/app/api/og/route.tsx new file mode 100644 index 000000000..8f19cb8bf --- /dev/null +++ b/examples/app-router/app/api/og/route.tsx @@ -0,0 +1,72 @@ +import { ImageResponse } from "next/og"; +// App router includes @vercel/og. +// No need to install it. +// ?title= + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + + // ?title=<title> + const hasTitle = searchParams.has("title"); + const title = hasTitle + ? searchParams.get("title")?.slice(0, 100) + : "My default title"; + + return new ImageResponse( + <div + style={{ + backgroundColor: "black", + backgroundSize: "150px 150px", + height: "100%", + width: "100%", + display: "flex", + textAlign: "center", + alignItems: "center", + justifyContent: "center", + flexDirection: "column", + flexWrap: "nowrap", + }} + > + <div + style={{ + display: "flex", + alignItems: "center", + justifyContent: "center", + justifyItems: "center", + }} + > + <img + alt="Vercel" + height={200} + src="data:image/svg+xml,%3Csvg width='116' height='100' fill='white' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M57.5 0L115 100H0L57.5 0z' /%3E%3C/svg%3E" + style={{ margin: "0 30px" }} + width={232} + /> + </div> + <div + style={{ + fontSize: 60, + fontStyle: "normal", + letterSpacing: "-0.025em", + color: "white", + marginTop: 30, + padding: "0 120px", + lineHeight: 1.4, + whiteSpace: "pre-wrap", + }} + > + {title} + </div> + </div>, + { + width: 1200, + height: 630, + }, + ); + } catch (e: any) { + return new Response("Failed to generate the image", { + status: 500, + }); + } +} diff --git a/examples/app-router/app/og/opengraph-image.tsx b/examples/app-router/app/og/opengraph-image.tsx new file mode 100644 index 000000000..69083bf70 --- /dev/null +++ b/examples/app-router/app/og/opengraph-image.tsx @@ -0,0 +1,36 @@ +import { ImageResponse } from "next/og"; + +// Image metadata +export const alt = "OpenNext"; +export const size = { + width: 1200, + height: 630, +}; + +export const contentType = "image/png"; + +// Image generation +export default async function Image() { + return new ImageResponse( + // ImageResponse JSX element + <div + style={{ + fontSize: 128, + background: "white", + width: "100%", + height: "100%", + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + > + OpenNext + </div>, + // ImageResponse options + { + // For convenience, we can re-use the exported opengraph-image + // size config to also set the ImageResponse's width and height. + ...size, + }, + ); +} diff --git a/examples/app-router/app/og/page.tsx b/examples/app-router/app/og/page.tsx new file mode 100644 index 000000000..0b98f2d64 --- /dev/null +++ b/examples/app-router/app/og/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return <div></div>; +} diff --git a/packages/tests-e2e/tests/appRouter/og.test.ts b/packages/tests-e2e/tests/appRouter/og.test.ts new file mode 100644 index 000000000..734d99b56 --- /dev/null +++ b/packages/tests-e2e/tests/appRouter/og.test.ts @@ -0,0 +1,60 @@ +import { createHash } from "node:crypto"; +import { expect, test } from "@playwright/test"; + +// This is the md5sums of the expected PNGs generated with `md5sum <file>` +const OG_MD5 = "6e5e794ac0c27598a331690f96f05d00"; +const API_OG_MD5 = "cac95fc3e2d4d52870c0536bb18ba85b"; + +function validateMd5(data: Buffer, expectedHash: string) { + return createHash("md5").update(data).digest("hex") === expectedHash; +} + +test("Open-graph image to be in metatags and present", async ({ + page, + request, +}) => { + await page.goto("/og"); + + // Wait for meta tags to be present + const ogImageSrc = await page + .locator('meta[property="og:image"]') + .getAttribute("content"); + const ogImageAlt = await page + .locator('meta[property="og:image:alt"]') + .getAttribute("content"); + const ogImageType = await page + .locator('meta[property="og:image:type"]') + .getAttribute("content"); + const ogImageWidth = await page + .locator('meta[property="og:image:width"]') + .getAttribute("content"); + const ogImageHeight = await page + .locator('meta[property="og:image:height"]') + .getAttribute("content"); + + // Verify meta tag exists and is the correct values + expect(ogImageSrc).not.toBe(null); + expect(ogImageAlt).toBe("OpenNext"); + expect(ogImageType).toBe("image/png"); + expect(ogImageWidth).toBe("1200"); + expect(ogImageHeight).toBe("630"); + + // Check if the image source is working + const response = await request.get(`/og/${ogImageSrc?.split("/").at(-1)}`); + expect(response.status()).toBe(200); + expect(response.headers()["content-type"]).toBe("image/png"); + expect(response.headers()["cache-control"]).toBe( + "public, immutable, no-transform, max-age=31536000", + ); + expect(validateMd5(await response.body(), OG_MD5)).toBe(true); +}); + +test("next/og (vercel/og) to work in API route", async ({ request }) => { + const response = await request.get("api/og?title=opennext"); + expect(response.status()).toBe(200); + expect(response.headers()["content-type"]).toBe("image/png"); + expect(response.headers()["cache-control"]).toBe( + "public, immutable, no-transform, max-age=31536000", + ); + expect(validateMd5(await response.body(), API_OG_MD5)).toBe(true); +});