Article notes
Notes, guides, and personal discoveries from things I build, enjoy, and learn.
Open Graph images have an awkward place in a web project.
They are visual assets, but they are usually generated by code. They depend on content, but they should not make every page build pay the rendering cost. They belong to a brand, but the machinery that writes, caches, and verifies them is mostly the same from one product to another.
I kept rebuilding that machinery.
Each project had its own script for finding content, composing SVG, loading fonts, calling an image library, choosing output paths, and deciding whether an existing file was current. The card designs were intentionally different. The pipeline bugs were repetitive.
That is why I built @santi020k/og, a renderer-agnostic Open Graph image generator for Astro, Next.js, plain Node.js, monorepos, CMS data, Markdown collections, and static arrays.
The package owns generation mechanics. The project keeps its design.
The boundary matters more than the template
Many social-image tools start by offering a good default card. That can be useful, but it couples the reusable layer to assumptions about titles, descriptions, logos, spacing, and visual identity.
I wanted the reusable contract to stop earlier.
A project gives @santi020k/og a list of cards and a renderer. The data can have any serializable shape. The renderer can return SVG, use the bundled Sharp or Satori integration, or own the entire rendering path. Fonts, logos, templates, content discovery, and brand rules remain in the application.
The generator handles everything around that renderer:
- validating and resolving output paths
- choosing an encoder from the file extension
- limiting concurrent work
- fingerprinting content and source files
- skipping unchanged cards
- removing tracked obsolete output
- checking committed images in CI
This turns the package into infrastructure rather than a visual preset.
Determinism requires more than hashing the title
A card can change even when its page title does not.
The description may change. The renderer may use a different font. A logo may be replaced. The template can move an element or change a color. The dimensions or output format can change. A worker module can contain new rendering logic.
The default cache fingerprint accounts for the card data, dimensions, output name, configuration contents, and declared source files. A worker renderer module is included automatically. Projects can declare shared sources such as templates, fonts, or logos once, and individual cards can add their own dependencies.
That makes the manifest useful for two workflows.
When social images are build artifacts, santi-og generate skips output whose fingerprint is current. When images are committed to the repository, santi-og check fails if an output is missing or stale without changing files.
The second workflow is especially important in pull requests. CI can verify generated artifacts without creating a dirty checkout or silently repairing a contributor’s branch.
Cleanup should know what it owns
Generation scripts often implement cleanup by scanning an output directory and deleting files that are not in the current input set.
That is risky if the directory contains assets owned by another process or if a configuration mistake points at the wrong place.
With clean: true, @santi020k/og removes only obsolete outputs recorded in the previous manifest. It does not claim ownership of every file it can see. Output paths and the manifest are constrained to the project root, which also prevents traversal outside the repository.
The rule is simple: cleanup can remove an artifact only when the generator has evidence that it created and tracked it.
Large collections need bounded parallelism
Rendering one image at a time leaves CPU capacity unused. Rendering every image at once can exhaust memory, especially when native image libraries and large fonts are involved.
The package supports bounded concurrency for ordinary renderers and a worker-module model for larger collections. A project exports a renderer from a separate module, and the CLI creates a controlled worker pool. Card data crosses the boundary through structured cloning, so the contract remains explicit.
Automatic concurrency is a useful default. A fixed limit remains available for constrained CI environments.
The important part is not maximum throughput. It is predictable throughput that does not make a build unreliable.
Output format is part of the path
The output extension selects SVG, PNG, WebP, JPEG, or AVIF encoding.
That keeps the card definition direct: docs/getting-started.webp describes both where the artifact belongs and how it should be encoded. The Sharp adapter can take an SVG-producing function and handle the raster formats. The Satori entry point adds Satori, Resvg-compatible output, font configuration, and an HTML-like template helper.
Projects that need another pipeline can provide it through the same renderer contract.
Reuse the pipeline, preserve the identity
The strongest reusable developer tools have a clear opinion about what they own.
@santi020k/og is opinionated about deterministic fingerprints, safe paths, tracked cleanup, bounded work, and non-mutating CI checks. It is deliberately unopinionated about what a card should look like or what fields a project must put on it.
That separation means I can use one reliable pipeline across products whose brands should never look interchangeable.
You can read the documentation, view the source, or see the shorter portfolio case study.
Comments
Powered by GitHub Discussions — sign in with GitHub to join the conversation, orreply on GitHub.


