Open Graph Images Generator
Generate social sharing thumbnails for your websites, with plain HTML +
CSS templates.
Extract metadata from pages, on-the-fly (middleware) or from distributable
(static folder).
No headless browser involved = fast cold boot, much less MBs.
Exposes all underlying APIs for full output customization.
Usable as a CLI, an API or via plugins for Astro, Express, Rollup, Vite and Web Dev Server.
Moreover, a handful of helpers + hot module reloading are here to ease poster image authoring.
Under the hood, it will transform your HTML / CSS to SVG, while retaining
layout and typography calculations, then it’s converted to PNG.
You can use gradients, borders, flexboxes, inline SVGs, and
more…
Additional resources
Installation
npm i og-images-generator
Create a og-images.config.js in your current workspace root.
See this og-images.example-config.js for a full working example. It’s the config used in every demo.
The gist is:
// ./og-images.config.js
import { htmlfunction html(strings: TemplateStringsArray, ...values: unknown[]): ServerRenderedTemplateA lit-html template that can only be rendered on the server, and cannot be
hydrated.
These templates can be used for rendering full documents, including the
doctype, and rendering into elements that Lit normally cannot, like
<title>, <textarea>, <template>, and non-executing <script> tags
like <script type="text/json">. They are also slightly more efficient than
normal Lit templates, because the generated HTML doesn't need to include
markers for updating.
Server-only html templates can be composed, and combined, and they support
almost all features that normal Lit templates do, with the exception of
features that don't have a pure HTML representation, like event handlers or
property bindings.
Server-only html templates can only be rendered on the server, they will
throw an Error if created in the browser. However if you render a normal Lit
template inside a server-only template, then it can be hydrated and updated.
Likewise, if you place a custom element inside a server-only template, it can
be hydrated and update like normal.
A server-only template can't be rendered inside a normal Lit template.
, styledconst styled: {
div: typeof dummyLiteral;
}
Very useful for inline styles.
[!TIP]
We just need a styled.div for our dummy literal, and for the Styled extension to pick-up
and highlight / format / get insights from inline CSS.
Checkout styled-components.vscode-styled-components on the VS Code marketplace.
VSCode extension: styled-components.vscode-styled-components
const myStyle = styled.div`
font-weight: 700;
font-size: 70px;
color: white;
`;
, OG_SIZEconst OG_SIZE: {
readonly width: 1200;
readonly height: 630;
}
, FONTSconst FONTS: {
readonly sourceSans: () => Promise<{
name: string;
data: ArrayBuffer;
}>;
}
} from 'og-images-generator';
/** @type {import('og-images-generator').PathsOptions} (Optional) */
export const pathsconst paths: PathsOptions = {
// DEFAULTS:
basePathsOptions.base?: string | undefined : './dist',
outPathsOptions.out?: string | undefined : './dist/og',
jsonPathsOptions.json?: string | undefined : './dist/og/index.json',
};
const myInlineStyle1const myInlineStyle1: string = styledconst styled: {
div: typeof dummyLiteral;
}
Very useful for inline styles.
[!TIP]
We just need a styled.div for our dummy literal, and for the Styled extension to pick-up
and highlight / format / get insights from inline CSS.
Checkout styled-components.vscode-styled-components on the VS Code marketplace.
VSCode extension: styled-components.vscode-styled-components
const myStyle = styled.div`
font-weight: 700;
font-size: 70px;
color: white;
`;
.divdiv: (templateStrings: TemplateStringsArray, ...arguments_: (string | string[])[]) => string `
display: flex;
`;
const nestedTemplate1const nestedTemplate1: ServerRenderedTemplate = htmlfunction html(strings: TemplateStringsArray, ...values: unknown[]): ServerRenderedTemplateA lit-html template that can only be rendered on the server, and cannot be
hydrated.
These templates can be used for rendering full documents, including the
doctype, and rendering into elements that Lit normally cannot, like
<title>, <textarea>, <template>, and non-executing <script> tags
like <script type="text/json">. They are also slightly more efficient than
normal Lit templates, because the generated HTML doesn't need to include
markers for updating.
Server-only html templates can be composed, and combined, and they support
almost all features that normal Lit templates do, with the exception of
features that don't have a pure HTML representation, like event handlers or
property bindings.
Server-only html templates can only be rendered on the server, they will
throw an Error if created in the browser. However if you render a normal Lit
template inside a server-only template, then it can be hydrated and updated.
Likewise, if you place a custom element inside a server-only template, it can
be hydrated and update like normal.
A server-only template can't be rendered inside a normal Lit template.
`<span>My Website</span>`;
/** @type {import('og-images-generator').Template} */
export const templateconst template: Template = ({ pagepage: Page }) =>
htmlfunction html(strings: TemplateStringsArray, ...values: unknown[]): ServerRenderedTemplateA lit-html template that can only be rendered on the server, and cannot be
hydrated.
These templates can be used for rendering full documents, including the
doctype, and rendering into elements that Lit normally cannot, like
<title>, <textarea>, <template>, and non-executing <script> tags
like <script type="text/json">. They are also slightly more efficient than
normal Lit templates, because the generated HTML doesn't need to include
markers for updating.
Server-only html templates can be composed, and combined, and they support
almost all features that normal Lit templates do, with the exception of
features that don't have a pure HTML representation, like event handlers or
property bindings.
Server-only html templates can only be rendered on the server, they will
throw an Error if created in the browser. However if you render a normal Lit
template inside a server-only template, then it can be hydrated and updated.
Likewise, if you place a custom element inside a server-only template, it can
be hydrated and update like normal.
A server-only template can't be rendered inside a normal Lit template.
` <!-- Contrived example -->
<div style=${myInlineStyle1const myInlineStyle1: string }>
${pagepage: Page .metaPage.meta?: Metadata | undefined ?.tagsMetadata.tags?: MetaTags | undefined ?.['og:title'] ?? 'Untitled'} <br />
${pagepage: Page .metaPage.meta?: Metadata | undefined ?.tagsMetadata.tags?: MetaTags | undefined ?.['og:description'] ?? 'No description'}
<!-- -->
${nestedTemplate1const nestedTemplate1: ServerRenderedTemplate }
<em>Nice</em>
<strong>Weather</strong>
</div>`;
/** @type {import('og-images-generator').RenderOptions} */
export const renderOptionsconst renderOptions: RenderOptions = {
satoriRenderOptions.satori: SatoriOptions : { fontsfonts: FontOptions[] : [await FONTSconst FONTS: {
readonly sourceSans: () => Promise<{
name: string;
data: ArrayBuffer;
}>;
}
.sourceSanssourceSans: () => Promise<{
name: string;
data: ArrayBuffer;
}>
()], ...OG_SIZEconst OG_SIZE: {
readonly width: 1200;
readonly height: 630;
}
},
};
At the minimum, you need to export renderOptions (with size and
font) and template from your og-images-generator configuration file.
paths are optional.
Note
Helpers
styled.div is a dummy strings concatenation literal (bringing syntax
highlighting and formatting).
div is the only needed (and available) tag, as it makes no difference anyway
for this sugar.
Also, you don’t need to wrap interpolated HTML attributes with quotes (e.g.
style="${foo}").
<foo-bar style=${styles.baz}></foo-bar> just works.
You can also just clone this repo. and play with the demos for your favorite
environments.
E.g.
git clone https://github.com/JulianCataldo/og-images-generator
cd og-images-generator
pnpm i -r # Recursive
cd demos/<…>
# Do the command(s) in the demo's README.
Usage
As a preamble, don’t forget to add the appropriate meta for your OGs, there are plenty of resources on the web on how to set up your SEO with your favorite environment.
That way, og-images-generator will crawl them back to your template.
It will parse all the meta tags (in the head) and JSON-LDs script tags content (in the head and body).
By default:
https://example.com/giveshttps://example.com/og/index.pnghttps://example.com/my-page/giveshttps://example.com/og/my-page.png
Warning
/ → index.png is an exception.
We don’t want https://example.com/og.png, as to keep this library output
well segregated from the rest of your dist folder.
That’s why so we need to disambiguate the root path.
For https://example.com:
<meta property="og:image" content="https://example.com/og/index.png" />
<meta property="og:image" content="https://example.com/og/nested/my-page.png" />
It’s a contrived example. Fine-tuning SEO tags is a dark, ancient art.
You’ll need the twitter: stuff and other massaging, so you’re sure it looks
great everywhere. But that’s really out of the scope of this library, which does
not mess with your HTML in the first place.
Alongside meta tags, JSON-LD blocks are also extracted and made available for your template to consume.
What if I need to attribute different templates depending on the page
route?
To achieve per URL template variations, add your branching logic in the root
template.
You can split and import full or partial templates accordingly if it grows too
much, or to organize styles separately.
Also, page.url is provided, alongside metadata (which should hold that info
too, like og:url).
Tip
Recommended VS Code extensions
- Styled Components for inline CSS highlighting:
styled-components.vscode-styled-components - HTML highlighting:
bierner.lit-html
Please note that the HTML to SVG engine under the hood
(Satori) has some limitations you have to be
aware of.
It’s kind of trial and error, but overall, you can achieve incomparable results
from pure SVGs, especially for things like typography and fluid layouts.
Hopefully, the example configuration will guide you towards some neat patterns I’m discovering empirically and collected here.
Tip
Vite and Astro are supporting automatic regeneration of your
template while your edit it, thanks to HMR.
It will even refresh your browser for you while you are visualizing your image
inside an HTML document.
CLI
npx generate-og
# defaults to
npx generate-og --base dist --out dist/og --json dist/og/index.json
Programmatic (JS API)
Use this API if you want to build your custom workflow, or create a plugin for unsupported dev/build tools or JS runtimes (e.g. “serverless” functions, Astro’s server endpoints…).
import * as apiimport api from 'og-images-generator/api';
await apiimport api .generateOgImagesfunction generateOgImages(options?: api.PathsOptions): Promise<void> (/* options */);
await apiimport api .renderOgImagefunction renderOgImage(userConfig: api.UserConfig, page?: api.Page): Promise<Buffer> (/* options */);
See also the tests folder for more minimal insights.
Express / Connect middleware
my-app.js:
import expressfunction express(): ExpressCreates an Express application. The express() function is a top-level function exported by the express module.
from 'express';
import { connectOgImagesGeneratorfunction connectOgImagesGenerator(options?: ConnectOgOptions): Promise<createServer.NextHandleFunction> } from 'og-images-generator/connect';
const appconst app: Express = expressfunction express(): ExpressCreates an Express application. The express() function is a top-level function exported by the express module.
();
appconst app: Express .useApplication<Record<string, any>>.use: (...handlers: RequestHandler<ParamsDictionary, any, any, QueryString.ParsedQs, Record<string, any>>[]) => Express (+8 overloads) (await connectOgImagesGeneratorfunction connectOgImagesGenerator(options?: ConnectOgOptions): Promise<createServer.NextHandleFunction> ());
appconst app: Express .getApplication<Record<string, any>>.get: <"/", {}, any, any, QueryString.ParsedQs, Record<string, any>>(path: "/", ...handlers: RequestHandler<{}, any, any, QueryString.ParsedQs, Record<string, any>>[]) => Express (+5 overloads) ('/', (__: Request<{}, any, any, QueryString.ParsedQs, Record<string, any>> , resres: Response<any, Record<string, any>, number> ) => {
resres: Response<any, Record<string, any>, number> .sendResponse<any, Record<string, any>, number>.send: (body?: any) => Response<any, Record<string, any>, number>Send a response.
Examples:
res.send(new Buffer('wahoo'));
res.send({ some: 'json' });
res.send('<p>some html</p>');
res.status(404).send('Sorry, cant find that');
(`
<html>
<head>
<meta property="og:title" content="Express / Connect demo" />
<meta property="og:description" content="Welcome to my website!" />
</head>
<body>
<img src="/og/index.png"/>
</body>
</html>
`);
});
appconst app: Express .listenApplication<Record<string, any>>.listen(port: number, callback?: (error?: Error) => void): Server (+5 overloads)Listen for connections.
A node http.Server is returned, with this
application (which is a Function) as its
callback. If you wish to create both an HTTP
and HTTPS server you may do so with the "http"
and "https" modules as shown here:
var http = require('http')
, https = require('https')
, express = require('express')
, app = express();
http.createServer(app).listen(80);
https.createServer({ ... }, app).listen(443);
(1234);
Web Dev Server
npm i express-to-koa
npm i -D @types/express-to-koa
web-dev-server.config.js:
import expressToKoaimport expressToKoa from 'express-to-koa';
import { connectOgImagesGeneratorfunction connectOgImagesGenerator(options?: ConnectOgOptions): Promise<createServer.NextHandleFunction> } from 'og-images-generator/connect';
/** @type {import('@web/dev-server').DevServerConfig} */
export default {
middlewaremiddleware: any[] : [expressToKoaimport expressToKoa (await connectOgImagesGeneratorfunction connectOgImagesGenerator(options?: ConnectOgOptions): Promise<createServer.NextHandleFunction> ())],
};
Rollup plugin
rollup.config.js:
import { rollupOgImagesGeneratorfunction rollupOgImagesGenerator(options?: PathsOptions): Plugin } from 'og-images-generator/rollup';
/** @type {import('rollup').RollupOptions} */
export default {
pluginsplugins: Plugin<any>[] : [
//
rollupOgImagesGeneratorfunction rollupOgImagesGenerator(options?: PathsOptions): Plugin (),
],
};
Vite plugin
vite.config.js:
import { defineConfigfunction defineConfig(config: UserConfig): UserConfig (+5 overloads)Type helper to make it easier to use vite.config.ts
accepts a direct
{@link
UserConfig
}
object, or a function that returns it.
The function receives a
{@link
ConfigEnv
}
object.
} from 'vite';
import { viteOgImagesGeneratorfunction viteOgImagesGenerator(options?: PathsOptions): PluginOption } from 'og-images-generator/vite';
export default defineConfigfunction defineConfig(config: UserConfig): UserConfig (+5 overloads)Type helper to make it easier to use vite.config.ts
accepts a direct
{@link
UserConfig
}
object, or a function that returns it.
The function receives a
{@link
ConfigEnv
}
object.
({
pluginsUserConfig.plugins?: PluginOption[] | undefinedArray of vite plugins to use.
: [
//
viteOgImagesGeneratorfunction viteOgImagesGenerator(options?: PathsOptions): PluginOption (),
],
buildbuild?: BuildEnvironmentOptions | undefinedBuild specific options
: {
rollupOptionsBuildEnvironmentOptions.rollupOptions?: RolldownOptions | undefinedAlias to rolldownOptions
: {
inputInputOptions.input?: InputOption | undefinedDefines entries and location(s) of entry modules for the bundle. Relative paths are resolved based on the
{@linkcode
cwd
}
option.
: {
foofoo: string : 'pages/foo.html',
barbar: string : 'pages/bar.html',
},
},
},
});
Astro integration
astro.config.js:
import { defineConfigimport defineConfig } from 'astro/config';
import { astroOgImagesGeneratorfunction astroOgImagesGenerator(options?: PathsOptions): AstroIntegration } from 'og-images-generator/astro';
export default defineConfigimport defineConfig ({
integrationsintegrations: AstroIntegration[] : [
//
astroOgImagesGeneratorfunction astroOgImagesGenerator(options?: PathsOptions): AstroIntegration (),
],
});
Tip
You can leverage Astro’s server endpoints capabilities, paired with the
og-images-generator JS API and Content Collections (or any data
source).
See demos/astro/src/pages/og-endpoint-demo.ts.
Possible improvements
Use a worker pool for when batch rendering images (in build modes).
Do benchmarks to ensure it’s worth the complexity.
Explore externally included HTML snippets, and see how to style them.
For example, add Source Sans font styles (for demo purposes) and play with
<em></em>, <strong></strong>, <small></small>…
I’ve had mixed results here, due to the inline style limitation, but it’s worth
taking a look again.
Typically, I prefer to keep titles and descriptions in plain text (\n and \t
are safe) like My description.\nHey!.
I enforce this rule everywhere: package.json, in metas, in JSON-LD, etc. No
emojis either.
It’s usually better to avoid those fancy things here because you can’t control
how vendor’s crawlers will gobble up things. However, I do want to add some
styling support from rich text excerpts at some point, even if we will have to
be careful, as it opens up a can of worms.
Alongside meta tags and JSON-LD blocks, provide a way to consume HTML, e.g.
<template data-og-images></template>.
This will offer a way for users to do more advanced per-route template
injection, or for example, provide a rich-text description (see above).
Alongside all metadata, provide a way to consume
<script type="application/json" data-og-images></script>, which is typically
found in SSRed setups, where a JSON payload is embedded in the HTML document,
for further client hydration.
For now, a user who wants to transfer arbitrary data will have to abuse meta tags or JSON-LDs, which is not really optimal.
Notes on image optimization
If you’re running this on a server, you should use a CDN or any kind of
proxying + caching, to handle on-the-fly image optimizations, with the rest of
your assets.
Also AFAIK, all major social network crawlers are transforming and caching
assets themselves.
It’s their job to normalize optimizations in order to serve images to their
users efficiently.
References
- Vercel’s Satori: vercel/satori
- Nate Moore’s HTML to Satori AST adapter: natemoo-re/satori-html
- SVG to PNG conversion with resvg: yisibl/resvg-js
- Static HTML template literal authoring/rendering with Lit SSR: lit/ssr
Known issues
- Emojis are not working with the
graphemeImagesSatori option.
Workaround: use something like emoji-strip by wrapping your injected text. - When interpolating text into your template literal, Lit SSR is encoding
HTML entities
while Satori isn’t decoding them afterward. You could use Lit’s
unsafeHTMLbut you’ll encounter a bug (mixed dev/prod).
In the meantime,og-images-generatorwill decode the full resulting template with theentitieslibrary, which could have some side effects (file an issue if that’s the case).
Other projects 👀…
- retext-case-police:
Check popular names casing. Example: ⚠️
github→ ✅GitHub. - remark-lint-frontmatter-schema: Validate your Markdown frontmatter data against a JSON schema.
- JSON Schema Form Element: Effortless forms, with standards.