# OG Images (https://auraimage.ai/docs/og-images)



An **OG image** is the social preview a link shows when someone shares it. The usual way to make one dynamic is a serverless route running Satori. That buys you a cold start per card, a font bundled as base64, and whatever cache your host happens to give you.

AuraImage does it as a URL instead. You push an **OG template** to your project once, and every card is a Render URL that renders on first request and is then cached globally like any other transform.

```html
<meta property="og:image" content="https://cdn.auraimage.ai/v1/og/my-app/blog-post?title=Hello%20world" />
```

Rendering runs on [Takumi](https://takumi.kane.tw), an open-source Rust renderer that turns HTML and CSS into PNG, JPEG, or WebP with no headless browser. It runs on our origin, not in your app.

## The five-minute version [#the-five-minute-version]

<Tabs items="['CLI', 'MCP', 'curl']">
  <Tab value="CLI">
    Install the skill. Four starter templates come with it, each linked from the [gallery below](#starters).

    ```sh
    npx skills add auraimage/skills/auraimage-og
    ```

    Copy the closest starter into `og/blog-post.html` and edit that.

    Both commands below read `AURA_PROJECT` and `AURA_SECRET_KEY` from `.env.local`, and they stop with a named error if either is missing. `aura init` prints both; [Getting Started](/docs/getting-started) has the long version.

    Now look at the card before anyone else does. `preview` renders locally with the same engine the origin uses.

    ```sh
    aura og preview ./og/blog-post.html --var title="Hello world" --var kicker=Engineering \
      --var "author=Ada Lovelace" --var "cover=w=768,h=630,fit=cover/blog/hero" --out preview.png
    ```

    Push it when the PNG looks right. `blog-post` has four holes. Declare a default for the three that rarely change and the URL only has to carry the title.

    ```sh
    aura og push blog-post ./og/blog-post.html \
      --default kicker=Engineering --default "author=Ada Lovelace" \
      --default "cover=w=768,h=630,fit=cover/blog/hero"
    ```
  </Tab>

  <Tab value="MCP">
    Ask your agent, with the [MCP server](/docs/ai-integration) configured:

    > Write an OG template for our blog posts and push it as `blog-post`.

    The agent writes the file and calls `push_og_template`, which takes the markup (as `html` or a `file` path) plus `width`, `height`, `fonts`, `defaults`, and `quality`, and returns the Render URL and a filled-in example. If the template breaks a rule, the error names the exact offender and the agent fixes that one thing.
  </Tab>

  <Tab value="curl">
    ```sh
    curl -X PUT "https://cdn.auraimage.ai/v1/og-templates/my-app/blog-post" \
      -H "Authorization: Bearer $AURA_SECRET_KEY" \
      -H "Content-Type: application/json" \
      -d "$(jq -n --rawfile html og/blog-post.html '{html: $html, width: 1200, height: 630,
            defaults: {kicker: "Engineering", author: "Ada Lovelace", cover: "w=768,h=630,fit=cover/blog/hero"}}')"
    ```
  </Tab>
</Tabs>

The push prints the Render URL. Point `og:image` at it and you are done.

## Writing a template [#writing-a-template]

A template is **data, not code**: markup with text holes, never a program. The rules below are all enforced at push, each with an error that names the offender, because a card that silently loses its styling or ships blank to Twitter is the worst possible failure mode.

```html
<div tw="flex h-full w-full flex-col justify-between bg-[#111014] px-16 py-14">
  <div tw="flex text-[22px] text-[#b6b3c6]">{{kicker}}</div>
  <div tw="flex text-[64px] font-bold leading-[1.06] text-[#f5f4f8]">{{title}}</div>
  <div tw="flex text-[24px] text-[#9793a8]">{{author}}</div>
</div>
```

### Tailwind goes in `tw`, not `class` [#tailwind-goes-in-tw-not-class]

The renderer reads Tailwind utilities from a `tw` attribute. A `class` attribute is for classes a `<style>` block in the same template defines, which is worth reaching for when the same rule applies to several elements:

```html
<div tw="flex h-full w-full flex-col bg-[#fbfbfd] px-20 py-16">
  <style>.rule{display:flex;width:100%;height:1px;background-color:#e2e0ea}</style>
  <div class="rule"></div>
</div>
```

A `class` token with no matching rule is a `400`. This one is strict on purpose: the renderer would ignore it silently, and you would find out from a screenshot on X.

### Variables are text [#variables-are-text]

`{{name}}` is a hole filled from the Render URL's query. Names match `[a-z][a-z0-9_]*`. A value is always plain text, HTML-escaped on the way in, and capped at 500 characters, so it can never introduce markup.

A variable may **not** appear inside a `tw` attribute, a `style` attribute, a `<style>` block, or inline `<svg>` markup. Substitution happens before parsing, so a hole in one of those positions would let a URL parameter become a CSS declaration.

A hole with no value and no declared default is a `400` naming it. So is an unknown parameter. There is no such thing as a blank card.

### Defaults are declared at push, not in the markup [#defaults-are-declared-at-push-not-in-the-markup]

```sh
aura og push blog-post ./og/blog-post.html --default kicker=Engineering
```

That one flag makes `kicker` optional in the URL. A hole with no default has to be in every Render URL, so declare one for everything that rarely changes and let the URL carry what does.

Everything about a template except the design lives in the push, which is why the flags belong in a script:

```json
{
  "scripts": {
    "og:push": "aura og push blog-post ./og/blog-post.html --default kicker=Engineering --default \"author=Ada Lovelace\" --default \"cover=w=768,h=630,fit=cover/blog/hero\""
  }
}
```

### Fonts [#fonts]

The built-in font is Geist, and using it means the render fetches no font at all. It carries Latin glyphs only. Cyrillic, Armenian, CJK, and every other script render as empty boxes unless you declare a family that covers them, which is a failure crawlers will cache. Declare up to four Google Fonts families by name at push:

```sh
aura og push blog-post ./og/blog-post.html --font Inter --font Fraunces
```

The name is validated against Google Fonts when you push, so a typo fails there instead of rendering in a fallback face for a month. There is no custom font upload, and no URL you can point at: one fixed trusted host.

### Images come from your project [#images-come-from-your-project]

Every image in a card, static or from a slot, is a **project-relative serve path**, the same grammar as a [Serve URL](/docs/url-api) minus the project prefix:

```html
<img src="w=1200/blog/hero" />        <!-- fixed picture -->
<img src="{{cover}}" />               <!-- image slot, filled from the URL -->
```

An **image slot** is a variable used as an image `src`. Its value is a serve path, so the URL decides which image:

```
?cover=w%3D768%2Ch%3D630%2Cfit%3Dcover%2Fblog%2Fhero
```

Absolute URLs, `data:` URIs, and CSS `url()` are rejected. A render never fetches an image from outside your project, so there is no way to point a card at a third party, and no way for a hot card to make us hammer one. Upload the image to the project first. The only hosts a render ever reaches are Google Fonts, for the families the template declared, and the Twemoji CDN, for emoji glyphs.

A slot always embeds a bounded variant, never a master: width snaps up the [dimension ladder](/docs/url-api#the-dimension-ladder) and caps at 2048, the default is 1536 wide, and the format defaults to WebP. A private image is refused, because the Render URL is public.

## The Render URL [#the-render-url]

```
https://cdn.auraimage.ai/v1/og/{project}/{template}[.png|.jpg|.jpeg|.webp]?{variables}
```

**No extension means PNG**, deterministically. This is the one place AuraImage does not negotiate a format, and it is deliberate: the consumer is a crawler reading a meta tag, not a browser sending an `Accept` header. `.jpg`, `.jpeg`, and `.webp` pin those formats, and `.png` is accepted for the people who like it spelled out. `.avif` is a `400`, because no social platform wants one.

Quality is a property of the template (`--quality`), not the URL. **Size is never in the URL**: a template's canvas is fixed, and a card at another size is another template. Only `v` is reserved, and you will rarely need it (see caching, below).

### Building it from code [#building-it-from-code]

```ts
import { buildOgUrl } from '@auraimage/sdk';

buildOgUrl({
  cdnUrl: process.env.NEXT_PUBLIC_AURA_CDN_URL!,
  project: 'my-app',
  template: 'blog-post',
  format: 'png', // or 'jpeg' | 'webp'; omit for PNG
  vars: { kicker: 'Engineering', title: 'Rendering OG cards without a browser' }
});
// → https://cdn.auraimage.ai/v1/og/my-app/blog-post?kicker=Engineering&title=Rendering%20OG%20cards%20without%20a%20browser
```

Both `'png'` and an omitted `format` emit no extension, which is what a PNG Render URL looks like. It sorts variables by key so the same content always produces the same URL and the same cache entry, and it throws naming the offender rather than emitting a URL the CDN would reject.

### Next.js [#nextjs]

```ts
import type { Metadata } from 'next';

import { buildOgUrl } from '@auraimage/sdk';

export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);

  return {
    title: post.title,
    openGraph: {
      images: [
        {
          url: buildOgUrl({
            cdnUrl: process.env.NEXT_PUBLIC_AURA_CDN_URL!,
            project: process.env.NEXT_PUBLIC_AURA_PROJECT_NAME!,
            template: 'blog-post',
            vars: {
              kicker: post.section,
              title: post.title,
              author: post.author,
              cover: `w=768,h=630,fit=cover/blog/${slug}`
            }
          }),
          width: 1200,
          height: 630
        }
      ]
    }
  };
}
```

No route handler, no `runtime = 'edge'`, no font in your bundle.

### Anything else [#anything-else]

```html
<meta property="og:image" content="https://cdn.auraimage.ai/v1/og/my-app/blog-post?title=Hello%20world" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
```

## Caching [#caching]

Templates are the one deliberate exception to AuraImage's immutable-cache contract. Pushing a new version changes the render's cache identity, so **every Render URL built from that template reflects the new design within about a minute**, with no cache-buster and no purge. The exact number is the 60-second edge TTL on the template's manifest. A template edit is design iteration, and an immutable contract would fight the workflow.

That refreshes our cache. It does not refresh what a social platform has already scraped for a page: that is their cache, and they re-scrape on their own schedule. When you need it now, use [Facebook's Sharing Debugger](https://developers.facebook.com/tools/debug/) or X's Card Validator to force a re-scrape.

## Ceilings [#ceilings]

Renders are not metered. Plans meter bandwidth and storage and nothing else, so a card costs exactly what its bytes cost, on Free as on every other plan. The numbers below are abuse ceilings, and each one is a named error.

| Limit                       | Value              | Error                          |
| --------------------------- | ------------------ | ------------------------------ |
| Templates per project       | 20                 | `409`                          |
| Template HTML               | 65,536 bytes       | `400`                          |
| Font families per template  | 4                  | `400`                          |
| Variable value              | 500 characters     | `400`                          |
| Query string                | 4,096 characters   | `400`                          |
| Canvas width and height     | 100 to 4096        | `400`                          |
| Cold renders per project    | 60 per minute      | `429` with `Retry-After: 60`   |
| Cold renders, platform-wide | 20,000 per UTC day | `503` with `Retry-After: 3600` |

Only a cache miss reaches the renderer, so a cached Render URL counts against nothing. A card that goes viral is one cold render and then a very large number of hits.

## Starters [#starters]

Four templates ship with the [`auraimage-og` skill](/docs/skills). Copy one into your `og/` directory and edit it. They compile as they are, and they declare no fonts, so no `--font` flag. Carry each one's canvas into `--width` and `--height`, which for three of the four is the 1200 by 630 default and for `square-card` is not.

<OgStarterGallery />

```sh
npx skills add auraimage/skills/auraimage-og
```

## Managing templates [#managing-templates]

```sh
aura og list                # every template, with its canvas, variables, and Render URL
aura og rm blog-post --yes  # remove one; its Render URLs start 404ing within a minute
```

`aura og list --json` prints an object with a `templates` array, which is the shape a script wants. `aura og rm` asks before it removes anything unless you pass `--yes`.

The [dashboard](/docs/dashboard) shows the same list with a live preview you can type into. It does not edit templates: the file in your repo is the source of truth, and push is idempotent, so a second editing surface would only be a second source of truth to disagree with.

## Reading a rejection [#reading-a-rejection]

Every rejection names exactly one thing. Fix that one thing.

| Message                                                                | Fix                                                                       |
| ---------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| `class 'flex' is not defined in a <style> block`                       | Move the utility into `tw`, which is where the message points.            |
| `variable '{{c}}' may not appear in a tw attribute`                    | A variable is text. Take it out of the style position.                    |
| `image src 'https://…': images must be uploaded to this project first` | Upload it, then reference `w=1200/blog/hero`.                             |
| `font family 'Intr' was not found on Google Fonts`                     | Check the spelling of the family.                                         |
| `missing variable 'title' and the template declares no default`        | Pass it in the URL, or push a `--default`.                                |
| `unknown variable 'titel'`                                             | Typo in the query. The message lists every variable the template accepts. |
| `a project may have at most 20 OG templates`                           | `aura og rm` one first.                                                   |


## Related

- [URL API](https://auraimage.ai/docs/url-api)
- [AI Integration](https://auraimage.ai/docs/ai-integration)
- [Agent Skills](https://auraimage.ai/docs/skills)