Skip to content

Instantly share code, notes, and snippets.

@kitze
Last active February 7, 2025 16:38
Show Gist options
  • Save kitze/71307845a12b6474046d98df1463dafc to your computer and use it in GitHub Desktop.
Save kitze/71307845a12b6474046d98df1463dafc to your computer and use it in GitHub Desktop.
Typesafe meta image generation with next js and vercel/og
import fs from 'fs';
import path from 'path';
import { glob } from 'glob';
import { camelCase } from 'lodash';
const API_DIR = path.join(process.cwd(), 'pages', 'api');
const OUTPUT_FILE = path.join(process.cwd(), 'app', 'types', 'api-routes.ts');
interface RouteInfo {
path: string;
relativePath: string;
isOgRoute: boolean;
hasZodSchema: boolean;
}
function findApiRoutes(): RouteInfo[] {
const files = glob.sync('**/*.{ts,tsx}', {
cwd: API_DIR,
ignore: ['**/*.d.ts'],
});
return files.map((file) => {
const fullPath = path.join(API_DIR, file);
const content = fs.readFileSync(fullPath, 'utf-8');
return {
path: fullPath,
relativePath: file,
isOgRoute: content.includes('createOgImageHandler'),
hasZodSchema: content.includes('export const PropsSchema') || content.includes('const PropsSchema'),
};
});
}
function generateRouteKey(relativePath: string): string {
return camelCase(
relativePath
.replace(/\.page\.(ts|tsx)$/, '')
.replace(/\[([^\]]+)\]/g, '$$$1')
.replace(/\//g, '_')
);
}
function generateRouteTypes(routes: RouteInfo[]): string {
return routes.map((route) => {
const routeKey = generateRouteKey(route.relativePath);
if (!route.hasZodSchema) return '';
return `
/** ${route.relativePath} */
export type ${routeKey}Props = import('pages/api/${route.relativePath}').Props;`;
}).filter(Boolean).join('\n');
}
function generateRouteConstants(routes: RouteInfo[]): string {
return routes.map((route) => {
const routeKey = generateRouteKey(route.relativePath);
return `
/** ${route.relativePath} */
'${routeKey}': {
path: '${route.relativePath.replace(/\.page\.(ts|tsx)$/, '')}',
isOgRoute: ${route.isOgRoute},
},`;
}).join('\n');
}
function generateRouteMap(routes: RouteInfo[]): string {
const ogRoutes = routes.filter(r => r.isOgRoute);
if (ogRoutes.length === 0) return '';
const entries = ogRoutes.map(route => {
const routeKey = generateRouteKey(route.relativePath);
return ` ${routeKey}: null as unknown as ${routeKey}Props`;
});
return `
export const routeMap = {
${entries.join(',\n')}
} as const;`;
}
function generateTypesFile(routes: RouteInfo[]) {
const content = `// This file is auto-generated. DO NOT EDIT IT MANUALLY!
import { ParsedUrlQuery } from 'querystring';
import { z } from 'zod';
${generateRouteTypes(routes)}
${generateRouteMap(routes)}
export interface ApiRoute {
path: string;
isOgRoute: boolean;
}
export const ApiRoutes = {${generateRouteConstants(routes)}
} as const;
export type RouteKey = keyof typeof routeMap;
export type RouteProps<K extends RouteKey> = \`\${K}Props\` extends keyof typeof globalThis ? globalThis[\`\${K}Props\`] : never;
export type OgRouteKey = {
[K in RouteKey]: typeof ApiRoutes[K]['isOgRoute'] extends true ? K : never
}[RouteKey];
export type OgRouteConfig<K extends OgRouteKey = OgRouteKey> = {
route: K;
props: RouteProps<K>;
};
`;
fs.writeFileSync(OUTPUT_FILE, content);
console.log(`✨ Generated API routes type definitions at ${OUTPUT_FILE}`);
}
// Run the generator
const routes = findApiRoutes();
generateTypesFile(routes);
import Head from 'next/head';
import React from 'react';
import { appName, appDomain, appTitle } from 'app/config/appName';
import { RouteKey, RouteProps } from 'app/utils/og-utils';
import { buildOgImageUrl } from 'app/utils/og-utils';
const baseUrl = `https://${appDomain}/`;
type BaseMetaTagsProps = {
description?: string;
title?: string;
urlPath?: string;
};
type MetaTagsWithImageUrl = BaseMetaTagsProps & {
imageUrl: string;
ogConfig?: never;
};
type MetaTagsWithOgConfig<K extends RouteKey> = BaseMetaTagsProps & {
imageUrl?: never;
ogConfig: {
route: K;
props: RouteProps<K>;
};
};
type MetaTagsProps = MetaTagsWithImageUrl | MetaTagsWithOgConfig<RouteKey>;
const MetaTags: React.FC<MetaTagsProps> = (props) => {
const {
description = `${appName} - Keep your users in the loop`,
title = appName,
urlPath = '',
} = props;
const imageUrl = 'imageUrl' in props
? props.imageUrl
: buildOgImageUrl(props.ogConfig.route, props.ogConfig.props);
console.log(imageUrl);
const url = baseUrl + urlPath;
return (
<Head>
<title>{appTitle(title)}</title>
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="initial-scale=1.0, width=device-width" />
<meta name="title" content={title} />
<meta name="description" content={description} />
<meta property="og:type" content="website" />
<meta property="og:url" content={url} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={imageUrl} />
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content={url} />
<meta property="twitter:title" content={title} />
<meta property="twitter:description" content={description} />
<meta property="twitter:image" content={imageUrl} />
</Head>
);
};
export default MetaTags;
import { getUrlOrigin } from './url-utils';
import type { ogUserProps, ogProjectProps } from '../types/api-routes';
import { ApiRoutes } from '../types/api-routes';
import { ImageResponse } from '@vercel/og';
import { z } from 'zod';
import { ReactElement } from 'react';
import { routeMap } from '../types/api-routes';
export type RouteKey = keyof typeof routeMap;
export type RouteProps<K extends RouteKey> = typeof routeMap[K];
export function buildOgImageUrl<K extends RouteKey>(route: K, props: RouteProps<K>): string {
return `${getUrlOrigin()}/api/${ApiRoutes[route].path}?data=${encodeURIComponent(JSON.stringify(props))}`;
}
/**
* Creates a handler for OG image generation with type safety
*/
export function createOgImageHandler<T extends z.ZodType>(
schema: T,
Component: (props: { data: z.infer<T> }) => ReactElement,
options: {
width?: number;
height?: number;
} = {}
) {
return async function handler(req: Request) {
try {
const { searchParams } = new URL(req.url);
const result = schema.safeParse(JSON.parse(searchParams.get('data') || '{}'));
if (!result.success) {
return new Response(`Invalid parameters: ${result.error.message}`, { status: 400 });
}
return new ImageResponse(Component({ data: result.data }), {
width: options.width || 1200,
height: options.height || 630,
});
} catch (e: any) {
console.error(e);
return new Response(`Failed to generate image: ${e.message}`, { status: 500 });
}
}
}
//the vercel/og package cannot handle s3 bucket because even tho localhost:3000 is allowed in digital ocean, it still fails to load the image
//so we made api/s3-image.ts to proxy the image from the s3 bucket
export const getProxiedImageUrl = (imageId: string | null | undefined) => {
if (!imageId) return undefined;
return `/api/s3-image?id=${imageId}`;
};
import { z } from 'zod';
import { createOgImageHandler } from 'app/utils/og-utils';
const PropsSchema = z.object({
username: z.string(),
avatar: z.string().optional(),
bio: z.string().optional(),
projects: z.string(),
});
export type Props = z.infer<typeof PropsSchema>;
export const config = {
runtime: 'edge',
};
export const OgUserPagePreview = ({ data }: { data: Props }) => {
return (
<div tw="flex items-center justify-center w-full h-full">
{/* Background with gradient */}
<img
width={1200}
height={630}
src="https://images.unsplash.com/photo-1579547944082-fac44e416258?q=80&w=3500&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
alt="background"
tw="absolute inset-0 w-full h-full object-cover"
/>
{/* Content overlay */}
<div tw="flex items-center justify-center w-full h-full bg-black/30">
<div tw="flex items-center w-full px-16 gap-12">
{data.avatar && (
<div tw="flex items-center justify-center flex-shrink-0">
<img
width={280}
height={280}
src={data.avatar}
alt={data.username}
tw="rounded-full border-8 border-white/20"
/>
</div>
)}
<div tw="flex ml-12 flex-col justify-center gap-8">
<div tw="flex items-baseline">
<span tw="text-7xl font-light text-white/80 mr-0">@</span>
<h1 tw="text-8xl font-bold text-white">{data.username}</h1>
</div>
{data.bio && (
<div tw="flex max-w-2xl">
<p tw="text-3xl text-white/90">{data.bio}</p>
</div>
)}
<div tw="flex items-center text-4xl text-white/90">
<span>Shipping</span>
<span tw="font-bold mx-2">{data.projects}</span>
<span>projects</span>
<span tw="ml-2">🚀</span>
</div>
</div>
</div>
</div>
</div>
);
};
export default createOgImageHandler(PropsSchema, OgUserPagePreview);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment