├── .astro └── types.d.ts ├── .gitignore ├── README.md ├── astro.config.mjs ├── package-lock.json ├── package.json ├── public ├── favicon.svg └── fonts │ └── Optimistic_Display_Bold.ttf ├── src ├── content │ ├── config.ts │ └── post │ │ └── hello.md ├── env.d.ts ├── layouts │ └── BlogLayout.astro └── pages │ ├── blog │ ├── [slug].og.ts │ ├── post1.md │ └── post2.md │ ├── index.astro │ ├── index.og.ts │ └── post │ ├── [slug].astro │ └── [slug].og.ts └── tsconfig.json /.astro/types.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'astro:content' { 2 | export { z } from 'astro/zod'; 3 | export type CollectionEntry = 4 | (typeof entryMap)[C][keyof (typeof entryMap)[C]] & Render; 5 | 6 | type BaseSchemaWithoutEffects = 7 | | import('astro/zod').AnyZodObject 8 | | import('astro/zod').ZodUnion 9 | | import('astro/zod').ZodDiscriminatedUnion 10 | | import('astro/zod').ZodIntersection< 11 | import('astro/zod').AnyZodObject, 12 | import('astro/zod').AnyZodObject 13 | >; 14 | 15 | type BaseSchema = 16 | | BaseSchemaWithoutEffects 17 | | import('astro/zod').ZodEffects; 18 | 19 | type BaseCollectionConfig = { 20 | schema?: S; 21 | slug?: (entry: { 22 | id: CollectionEntry['id']; 23 | defaultSlug: string; 24 | collection: string; 25 | body: string; 26 | data: import('astro/zod').infer; 27 | }) => string | Promise; 28 | }; 29 | export function defineCollection( 30 | input: BaseCollectionConfig 31 | ): BaseCollectionConfig; 32 | 33 | type EntryMapKeys = keyof typeof entryMap; 34 | type AllValuesOf = T extends any ? T[keyof T] : never; 35 | type ValidEntrySlug = AllValuesOf<(typeof entryMap)[C]>['slug']; 36 | 37 | export function getEntryBySlug< 38 | C extends keyof typeof entryMap, 39 | E extends ValidEntrySlug | (string & {}) 40 | >( 41 | collection: C, 42 | // Note that this has to accept a regular string too, for SSR 43 | entrySlug: E 44 | ): E extends ValidEntrySlug 45 | ? Promise> 46 | : Promise | undefined>; 47 | export function getCollection>( 48 | collection: C, 49 | filter?: (entry: CollectionEntry) => entry is E 50 | ): Promise; 51 | export function getCollection( 52 | collection: C, 53 | filter?: (entry: CollectionEntry) => unknown 54 | ): Promise[]>; 55 | 56 | type InferEntrySchema = import('astro/zod').infer< 57 | Required['schema'] 58 | >; 59 | 60 | type Render = { 61 | render(): Promise<{ 62 | Content: import('astro').MarkdownInstance<{}>['Content']; 63 | headings: import('astro').MarkdownHeading[]; 64 | remarkPluginFrontmatter: Record; 65 | }>; 66 | }; 67 | 68 | const entryMap: { 69 | "post": { 70 | "hello.md": { 71 | id: "hello.md", 72 | slug: "hello", 73 | body: string, 74 | collection: "post", 75 | data: InferEntrySchema<"post"> 76 | }, 77 | }, 78 | 79 | }; 80 | 81 | type ContentConfig = typeof import("../src/content/config"); 82 | } 83 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | .output/ 4 | 5 | # dependencies 6 | node_modules/ 7 | 8 | # logs 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | pnpm-debug.log* 13 | 14 | 15 | # environment variables 16 | .env 17 | .env.production 18 | 19 | # macOS-specific files 20 | .DS_Store 21 | .vercel 22 | .astro -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vercel OG Image in Astro 2 | Generates OG Image using [satori](https://github.com/vercel/satori) served from [Astro Server Endpoints](https://docs.astro.build/en/core-concepts/endpoints/#server-endpoints-api-routes). 3 | Requires SSR Mode, See `output: 'server'` in `astro.config.mjs`. Deployed using [Vercel adapter for Astro](https://docs.astro.build/en/guides/integrations-guide/vercel/). 4 | 5 | Blog post: https://rumaan.dev/blog/open-graph-images-using-satori 6 | 7 | Deployed URL: https://astro-vercel-og.vercel.app/ 8 | 9 | More info: [Vercel OG Image Generation](https://vercel.com/docs/concepts/functions/edge-functions/og-image-generation) 10 | 11 | ![OG Image Generated from Vercel OG](https://astro-vercel-og.vercel.app/index.og) 12 | -------------------------------------------------------------------------------- /astro.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'astro/config'; 2 | 3 | // https://astro.build/config 4 | import vercel from "@astrojs/vercel/serverless"; 5 | 6 | // https://astro.build/config 7 | export default defineConfig({ 8 | output: "server", 9 | adapter: vercel({ 10 | includeFiles: ["./public/fonts/Optimistic_Display_Bold.ttf"] 11 | }) 12 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "astro-vercel-og", 3 | "type": "module", 4 | "version": "0.0.1", 5 | "private": true, 6 | "scripts": { 7 | "dev": "astro dev", 8 | "start": "astro dev", 9 | "build": "astro build", 10 | "preview": "astro preview", 11 | "astro": "astro" 12 | }, 13 | "dependencies": { 14 | "@astrojs/vercel": "~3.0.1", 15 | "astro": "~2.0.0", 16 | "satori": "^0.4.2", 17 | "satori-html": "^0.3.2", 18 | "sharp": "^0.31.3" 19 | }, 20 | "devDependencies": { 21 | "@types/node": "^18.15.0", 22 | "@types/sharp": "^0.31.1" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 13 | 14 | -------------------------------------------------------------------------------- /public/fonts/Optimistic_Display_Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rumaan/astro-vercel-og/0355a837c847d47c4f2c49eaee7c48246bb0ec49/public/fonts/Optimistic_Display_Bold.ttf -------------------------------------------------------------------------------- /src/content/config.ts: -------------------------------------------------------------------------------- 1 | import { defineCollection, z } from "astro:content"; 2 | 3 | const postsCollection = defineCollection({ 4 | schema: z.object({ 5 | title: z.string(), 6 | description: z.string() 7 | // ... 8 | }), 9 | }); 10 | 11 | export const collections = { 12 | 'post': postsCollection 13 | } 14 | -------------------------------------------------------------------------------- /src/content/post/hello.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Hello 3 | description: Hello Description Here 4 | --- 5 | 6 | # Howdy from Astro Collections!! -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /src/layouts/BlogLayout.astro: -------------------------------------------------------------------------------- 1 | --- 2 | const { frontmatter, url } = Astro.props; 3 | 4 | const ogUrl = `https://astro-vercel-og.vercel.app/${url}.og`; 5 | --- 6 | 7 | 8 | {frontmatter.title} 9 | 10 | 11 | 12 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 |

{frontmatter.title}

24 | OG Url of this page 25 | 26 |
27 | -------------------------------------------------------------------------------- /src/pages/blog/[slug].og.ts: -------------------------------------------------------------------------------- 1 | import satori from "satori"; 2 | import { html } from "satori-html"; 3 | import { readFileSync } from "fs"; 4 | import type { APIRoute, MarkdownInstance } from "astro"; 5 | import sharp from "sharp"; 6 | import { basename } from "path"; 7 | 8 | export const get: APIRoute = async ({ params }) => { 9 | const { slug } = params; 10 | // Find the slug in content dir 11 | const posts: Record Promise> = import.meta.glob( 12 | `./**/*.md` 13 | ); 14 | 15 | const postPaths = Object.entries(posts).map(([path, promise]) => ({ 16 | slug: basename(path).replace(".md", ""), 17 | loadPost: promise, 18 | })); 19 | 20 | const post = postPaths.find((p) => p.slug === String(slug)); 21 | let postTitle = `My Blog`; // Default title if post not found 22 | if (post) { 23 | const postData = (await post.loadPost()) as MarkdownInstance< 24 | Record 25 | >; 26 | postTitle = postData.frontmatter.title; 27 | } 28 | 29 | const fontFilePath = `${process.cwd()}/public/fonts/Optimistic_Display_Bold.ttf`; 30 | const fontFile = readFileSync(fontFilePath); 31 | const markup = html(`
34 |
37 | ${postTitle} 38 |
39 |
`); 40 | const svg = await satori(markup, { 41 | width: 1200, 42 | height: 630, 43 | fonts: [ 44 | { 45 | name: "Optimistic Display", 46 | data: fontFile, 47 | style: "normal", 48 | }, 49 | ], 50 | }); 51 | 52 | const png = sharp(Buffer.from(svg)).png(); 53 | const response = await png.toBuffer(); 54 | 55 | return new Response(response, { 56 | status: 200, 57 | headers: { 58 | "Content-Type": "image/png", 59 | "Cache-Control": "s-maxage=1, stale-while-revalidate=59", 60 | }, 61 | }); 62 | }; 63 | -------------------------------------------------------------------------------- /src/pages/blog/post1.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Hello World" 3 | layout: "../../layouts/BlogLayout.astro" 4 | --- 5 | 6 | Hello World content -------------------------------------------------------------------------------- /src/pages/blog/post2.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Post 2 3 | layout: "../../layouts/BlogLayout.astro" 4 | --- 5 | 6 | Hello from Post 2 -------------------------------------------------------------------------------- /src/pages/index.astro: -------------------------------------------------------------------------------- 1 | 2 | 3 | Vercel Astro 4 | 5 | 6 | 7 | 11 | 12 | 13 | 17 | 18 | 19 | 20 | 21 |

22 | Vercel Astro OG: Check here 23 |

24 | 25 | 26 | -------------------------------------------------------------------------------- /src/pages/index.og.ts: -------------------------------------------------------------------------------- 1 | import satori from "satori"; 2 | import { html } from "satori-html"; 3 | import { readFileSync } from "fs"; 4 | import type { APIRoute } from "astro"; 5 | import sharp from "sharp"; 6 | 7 | export const get: APIRoute = async () => { 8 | const fontFilePath = `${process.cwd()}/public/fonts/Optimistic_Display_Bold.ttf`; 9 | const fontFile = readFileSync(fontFilePath); 10 | const markup = html(`
13 |
16 | Hello from 18 | Astro 22 |
23 |
`); 24 | const svg = await satori(markup, { 25 | width: 1200, 26 | height: 630, 27 | fonts: [ 28 | { 29 | name: "Optimistic Display", 30 | data: fontFile, 31 | style: "normal", 32 | }, 33 | ], 34 | }); 35 | 36 | const png = sharp(Buffer.from(svg)).png(); 37 | const response = await png.toBuffer(); 38 | 39 | return new Response(response, { 40 | status: 200, 41 | headers: { 42 | "Content-Type": "image/png", 43 | "Cache-Control": "s-maxage=1, stale-while-revalidate=59", 44 | }, 45 | }); 46 | }; 47 | -------------------------------------------------------------------------------- /src/pages/post/[slug].astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { getEntryBySlug } from "astro:content"; 3 | import BlogLayout from "../../layouts/BlogLayout.astro"; 4 | 5 | const { slug } = Astro.params; 6 | 7 | const entry = await getEntryBySlug("post", slug!!); 8 | if (!entry) { 9 | return Astro.redirect("/404"); 10 | } 11 | 12 | const { Content } = await entry.render(); 13 | --- 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/pages/post/[slug].og.ts: -------------------------------------------------------------------------------- 1 | import type { APIRoute } from "astro"; 2 | import { getEntryBySlug } from "astro:content"; 3 | import { readFileSync } from "node:fs"; 4 | import { html } from "satori-html"; 5 | import satori from "satori"; 6 | import sharp from "sharp"; 7 | 8 | export const get: APIRoute = async ({ params }) => { 9 | const { slug } = params; 10 | 11 | const post = await getEntryBySlug("post", slug!!); 12 | const title = post?.data.title ?? "My Post"; 13 | 14 | const fontFilePath = `${process.cwd()}/public/fonts/Optimistic_Display_Bold.ttf`; 15 | const fontFile = readFileSync(fontFilePath); 16 | 17 | const markup = html(`
20 |
23 | ${title} 24 |
25 |
`); 26 | const svg = await satori(markup, { 27 | width: 1200, 28 | height: 630, 29 | fonts: [ 30 | { 31 | name: "Optimistic Display", 32 | data: fontFile, 33 | style: "normal", 34 | }, 35 | ], 36 | }); 37 | const png = sharp(Buffer.from(svg)).png(); 38 | const response = await png.toBuffer(); 39 | 40 | return new Response(response, { 41 | status: 200, 42 | headers: { 43 | "Content-Type": "image/png", 44 | "Cache-Control": "s-maxage=1, stale-while-revalidate=59", 45 | }, 46 | }); 47 | }; 48 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strict", 3 | "compilerOptions": { 4 | "strictNullChecks": true, 5 | "allowJs": true 6 | } 7 | } --------------------------------------------------------------------------------