Get started with 33% off your first certification using code: 33OFFNEW

How to generate blog hero images with the HTML to Image API

5 min read
Published on 19th June 2026

Every post you publish needs a featured image, and most teams make them one at a time in Figma or Canva. That does not scale, the style drifts as different people touch the files, and the designer becomes a bottleneck between writing a post and shipping it. This article shows you how to generate a consistent hero image from a JSON payload, so the image is produced at publish time and looks the same for every post.

Why generate the image instead of designing each one

A few reasons it pays to move this into code rather than a design tool. Your titles, authors and categories already live in your CMS, so passing them to a template means the hero always matches the post without anyone retyping anything. The output is identical in layout every time, which is the thing hand-made images never quite manage once more than one person is involved. And a render takes a fraction of a second, so a post can go from draft to live without waiting on a design queue.

The trade is that you give up per-post art direction. For a documentation site, a changelog or a high-volume blog, that is a good trade. For a launch piece that needs a bespoke illustration, design it by hand. Generation is for the long tail of posts that just need a clean, on-brand image.

The blog-hero template

HTML to Image ships a blog-hero template that takes the fields you would expect on a featured image and returns a hosted PNG. You send a JSON body, it renders a 1600x900 image (16:9, the right shape for a featured image and an Open Graph card), and you get back a CDN URL. There is nothing to host yourself and no headless browser to run.

You can see sample renders and the layout on the blog hero template page. The only required field is the title. Everything else is optional and fills in the parts of the layout you choose to use.

Your first render

Authentication is an X-API-Key header. Grab a key from your dashboard, then the smallest possible call is a single title:

curl -X POST https://app.html2img.com/api/v1/templates/blog-hero \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"title":"How to debounce input events in vanilla JavaScript"}'

A successful render comes back like this:

{
  "success": true,
  "id": "abc123",
  "url": "https://i.html2img.com/abc123.png",
  "credits_remaining": 1234,
  "template": "blog-hero"
}

The url is the finished image on the CDN. Store it against your post and use it wherever you set the featured image and the og:image meta tag.

A fuller payload uses more of the layout:

curl -X POST https://app.html2img.com/api/v1/templates/blog-hero \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "How to debounce input events in vanilla JavaScript",
    "subtitle": "Stop firing handlers on every keystroke.",
    "category": "Tutorials",
    "read_time": "6 min read",
    "author_name": "Jordan Lee",
    "author_role": "Engineer",
    "accent_color": "#22D3EE",
    "background_color": "#0F172A"
  }'

The inputs

The full set of fields the template accepts:

Field Type Required What it does
title string Yes The headline shown in the image.
subtitle string No A secondary line under the title.
category string No A category label, for example Tutorials.
read_time string No A read-time label, for example 6 min read.
author_name string No Author or brand name.
author_role string No A line under the author name.
author_avatar_url url No Avatar image pulled into the render.
background_image_url url No A background image behind the layout.
background_color string No Hex background colour.
accent_color string No Hex accent colour.

Width and height default to 1600 and 900, so you only set them if you want a different size.

Wiring it into a Laravel publish flow

The practical version is to render the hero once, when a post is created, and store the URL. Here is a small class that builds the payload from a post and returns the CDN URL:

use Illuminate\Support\Facades\Http;

class HeroImage
{
    public static function for(Post $post): string
    {
        $response = Http::withHeaders([
            'X-API-Key' => config('services.html2img.key'),
        ])->post('https://app.html2img.com/api/v1/templates/blog-hero', [
            'title'            => $post->title,
            'category'         => $post->category->name,
            'read_time'        => $post->read_time . ' min read',
            'author_name'      => $post->author->name,
            'author_role'      => $post->author->role,
            'background_color' => '#0F172A',
            'accent_color'     => '#22D3EE',
        ]);

        $response->throw();

        return $response->json('url');
    }
}

Call it from a model observer so authors never think about it:

class PostObserver
{
    public function created(Post $post): void
    {
        $post->forceFill([
            'hero_image_url' => HeroImage::for($post),
        ])->saveQuietly();
    }
}

saveQuietly writes the URL back without firing the observer again. Every new post then has a hero the moment it is created, built from data you already hold.

Doing it at build time with Node

If your site is static (Astro, Eleventy, Next and so on) you can render heroes during the build and commit the URLs, or fetch them per post in a content step:

async function heroFor(post) {
  const res = await fetch('https://app.html2img.com/api/v1/templates/blog-hero', {
    method: 'POST',
    headers: {
      'X-API-Key': process.env.HTML2IMG_KEY,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      title: post.title,
      category: post.category,
      read_time: `${post.readingTime} min read`,
      author_name: post.author,
      accent_color: '#22D3EE',
    }),
  });

  if (!res.ok) {
    throw new Error(`Hero render failed with ${res.status}`);
  }

  const { url } = await res.json();
  return url;
}

Handling errors

Check success before you trust the response. The endpoint returns a 422 when validation fails, with an errors object naming the field:

{
  "success": false,
  "error": "validation_failed",
  "errors": {
    "title": ["The title field is required."]
  }
}

A 401 means the API key is missing or wrong. A 429 means you have hit the rate limit or run out of monthly credits. In a publish flow, log the failure and fall back to a default image rather than blocking the post from going out. The Laravel throw() above turns a failed response into an exception you can catch in your queue.

Make it match your site

Two fields do most of the branding work. Set background_color and accent_color to your palette and every hero lands in the same colours as the rest of your site. Add author_avatar_url if you want the author's face on the card, or background_image_url for a photographic backdrop behind the text. Because the layout is fixed, posts stay visually consistent no matter who writes them.

The blog hero is one of a set. If you also need Open Graph cards, social posts, certificates or code screenshots, the same JSON-in, PNG-out pattern covers all of them. Browse the full template list to see what is available, and keep the blog hero API reference open while you wire it in.

What to do next

Make one render with a real title from your blog and look at the URL it returns. If the colours are right, drop the call into your publish step and point your featured image and og:image at the result. The manual design step for everyday posts is then gone, and every hero you ship matches the last one.