Last active
February 7, 2025 16:38
-
-
Save kitze/71307845a12b6474046d98df1463dafc to your computer and use it in GitHub Desktop.
Typesafe meta image generation with next js and vercel/og
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}`; | |
}; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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