DEV Community

Cover image for Replacing five Figma files with one HTML renderer for our content brand
Accreditly
Accreditly

Posted on

Replacing five Figma files with one HTML renderer for our content brand

The trigger was a brand refresh. The marketing director walked into a Tuesday standup with a new colour palette, a new display font, and a kind smile. The work to roll that out across our content brand was three weeks: I counted the Figma files (blog hero, quote card, podcast cover, episode card, newsletter banner), multiplied by the designer's day rate, and added a fortnight of "could you regenerate the hero image for these 300 archive posts" that I would have to do by hand.

What we shipped instead took a Saturday afternoon and one paid invoice. Every editorial image format moved into HTML templates. The brand variables landed in a single CSS file. A brand refresh became a five-minute edit, a cache bump, and a deploy. The back catalogue regenerated on next request.

This article is the pattern, with working code in Laravel, the bits I got wrong, and when this is the wrong approach.

What was in the stack

Five image formats. Each one had a Figma file. Each one was opened by a different person across the year and slowly drifted into a slightly different interpretation of the same brand.

  • Blog hero at 1600 by 900. Sits at the top of the article.
  • Quote card at 1200 by 1200. Pull-quotes for social.
  • Podcast cover at 1500 by 1500. Apple Podcasts and Spotify directories.
  • Episode card at 1200 by 630. Per-episode share image.
  • Email header at 1200 by 300. Newsletter banner. Three different aspect ratios, four different content data shapes, one brand language meant to feel like one thing across all of them. It did not. By the time the refresh landed, the back catalogue looked like a small museum of how the brand had been interpreted over 18 months.

The pattern

One Blade view per format. One CSS partial that defines the brand. One renderer service that handles all five.

namespace App\Services;

use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;

class EditorialImageRenderer
{
    private const FORMATS = [
        'blog-hero'     => ['w' => 1600, 'h' => 900,  'view' => 'editorial.blog-hero'],
        'quote-card'    => ['w' => 1200, 'h' => 1200, 'view' => 'editorial.quote-card'],
        'podcast-cover' => ['w' => 1500, 'h' => 1500, 'view' => 'editorial.podcast-cover'],
        'episode-card'  => ['w' => 1200, 'h' => 630,  'view' => 'editorial.episode-card'],
        'email-header'  => ['w' => 1200, 'h' => 300,  'view' => 'editorial.email-header'],
    ];

    public function render(string $format, array $data): string
    {
        $config = self::FORMATS[$format];
        $key = $this->cacheKey($format, $data);

        if (Storage::disk('s3')->exists($key)) {
            return Storage::disk('s3')->url($key);
        }

        $html = view($config['view'], $data)->render();

        $res = Http::withHeaders(['X-API-Key' => config('services.html2img.key')])
            ->post('https://app.html2img.com/api/html', [
                'html' => $html,
                'width' => $config['w'],
                'height' => $config['h'],
                'wait_for_selector' => '.ready',
            ])->throw()->json();

        $bytes = Http::get($res['url'])->throw()->body();
        Storage::disk('s3')->put($key, $bytes, 'public');

        return Storage::disk('s3')->url($key);
    }

    private function cacheKey(string $format, array $data): string
    {
        ksort($data);
        $hash = md5(json_encode([...$data, '_format' => $format, '_v' => '1']));
        return "editorial/{$format}/{$hash}.png";
    }
}
Enter fullscreen mode Exit fullscreen mode

The _v field in the cache key is the brand-refresh switch. Bumping it from '1' to '2' invalidates every cached image. The next request for each one regenerates against the current Blade view, which in turn pulls from the current brand partial.

The brand partial

This is the bit that earns the system its keep. Every per-format Blade view starts with @include('editorial._brand'). The partial defines the colour palette, fonts, and shared element styles. Nothing else in the system hard-codes a brand value.

{{-- resources/views/editorial/_brand.blade.php --}}
<style>
  @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700;900&family=Fraunces:wght@600;800;900&display=swap');
  * { margin: 0; padding: 0; box-sizing: border-box; }
  :root {
    --brand-bg: #FFFCF5;
    --brand-fg: #1A1A1A;
    --brand-accent: #C2410C;
    --brand-muted: #6B7280;
    --font-display: 'Fraunces', Georgia, serif;
    --font-body: 'Inter', sans-serif;
  }
  body {
    background: var(--brand-bg);
    color: var(--brand-fg);
    font-family: var(--font-body);
  }
  .brand-mark {
    font-family: var(--font-display);
    font-weight: 900;
    letter-spacing: -0.02em;
  }
  .ready { visibility: hidden; height: 0; }
</style>
Enter fullscreen mode Exit fullscreen mode

When the brand refreshed, the changes that landed were: --brand-bg from cream to slate, --font-display from Fraunces to Newsreader, --brand-accent from orange to teal. Three lines. One commit. The PR description was longer than the code change.

One format in full: the blog hero

Most-shared. Doubles as the Open Graph image. Worth showing in full.

{{-- resources/views/editorial/blog-hero.blade.php --}}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
@include('editorial._brand')
<style>
  body { width: 1600px; height: 900px; padding: 100px; display: grid; grid-template-rows: auto 1fr auto; }
  .category {
    font-size: 18px; font-weight: 700; letter-spacing: 4px;
    text-transform: uppercase; color: var(--brand-accent);
  }
  h1 {
    font-family: var(--font-display);
    font-size: 110px; font-weight: 900;
    line-height: 1.02; letter-spacing: -2px;
    align-self: end; max-width: 1300px;
  }
  footer {
    font-size: 24px; color: var(--brand-muted);
    display: flex; justify-content: space-between; align-items: end;
  }
  footer .author { font-weight: 700; color: var(--brand-fg); }
  .divider { width: 80px; height: 4px; background: var(--brand-fg); margin: 16px 0 32px; }
</style>
</head>
<body>
  <div>
    <div class="category">{{ $category }}</div>
    <div class="divider"></div>
  </div>
  <h1>{{ $title }}</h1>
  <footer>
    <span><span class="author">{{ $author }}</span> ยท {{ $published_at }}</span>
    <span class="brand-mark">{{ config('app.brand') }}</span>
  </footer>
  <div class="ready"></div>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

The display font carries most of the visual weight. A 110px title in Fraunces feels editorial in a way 110px in Inter does not. The grid layout anchors the category at the top, the title vertically centred, and the byline at the bottom: three rows that adapt to title length without manual tuning.

Triggering the render from the article model:

public function heroImageUrl(): string
{
    return app(EditorialImageRenderer::class)->render('blog-hero', [
        'title' => $this->title,
        'category' => $this->category->name,
        'author' => $this->author->name,
        'published_at' => $this->published_at->format('jS F Y'),
    ]);
}
Enter fullscreen mode Exit fullscreen mode

The article template embeds the result as a plain <img> tag. Same URL goes into the OG meta tag in the page head.

The other four formats

Each one follows the same shape. A Blade view sized to the viewport, including the brand partial, ending with a .ready element so the renderer knows when to capture.

The data shapes for the ones I haven't shown in full:

  • Quote card: quote, attribution, optional avatar_url. Big serif italic, an oversized quotation-mark glyph as an accent, the attribution underneath.
  • Podcast cover: show_name, host, tagline. Square. Generated rarely.
  • Episode card: episode_number, title, guest, optional topic. Generated automatically when each episode publishes.
  • Email header: issue_number, volume, date. Wide banner across the top of the newsletter template. If you would rather not build the Blade views, html2img has pre-built blog hero, quote card, podcast cover, podcast episode card and email header templates that accept the same data shape and return a sized PNG. The trade-off is the layout is opinionated. We use the HTML endpoint for the blog hero and quote card (most brand-defining) and the pre-built templates for the rest.

Gotchas

The list that bit me first.

Quote lengths are unpredictable. A 9-word pull-quote at 96px looks balanced. A 32-word quote at the same size overflows the card. Either drop the font size dynamically based on character count, or hard-cap the input length in the editor.

Brand partial cascade. I learned the hard way that putting the brand partial after the per-format <style> block lets the format override the brand. That's the opposite of what you want. The partial goes first, the format styles go after, format-specific rules override brand defaults where needed but the brand variables stay as the source of truth.

Webfonts and wait_for_selector. The convention of ending every template with a <div class="ready"> means the screenshot fires after the meaningful DOM is in place. Without it, the screenshot occasionally captures the page before the webfont has loaded and you ship a fallback render. Solved problem, but only if you remember the convention.

External images. Avatars hosted on third-party CDNs, hero photographs pulled from Cloudinary, brand marks loaded from another domain. Any of them can delay the render or fail outright. Inline your brand assets as SVG or base64. Pre-upload editorial photos to your own fast CDN, or accept that the render will occasionally miss them.

Cache key construction. ksort the data array before hashing. Without stable ordering, the same logical data hashed twice gives two different keys, which doubles your API calls. Took me a confused afternoon to spot.

Don't store the upstream URL. The API returns a CDN URL. Tempting to use it directly. We did, then ran into an outage where every link 404'd. Fetch the bytes once at render time, persist to your own storage, serve from your own CDN.

When this is the wrong approach

Two cases.

If your content output is one piece a fortnight and you have a designer who enjoys the work, the engineering cost is not worth it. The Figma stack has a real cost only at higher cadences.

If your editorial team wants pixel-level art direction on every image (full-bleed photography, custom illustrations per piece), HTML templates can't replace that. They can complement: hero images that need photography handled in Figma, everything else templated. Most content brands sit at the latter mix.

For everything else, the HTML renderer earned its keep in the brand refresh that triggered it. The thing it actually unlocks is content-team velocity: they can ship a quote card without waiting for the designer, because the template enforces the brand for them. That benefit shows up every week, not just at brand refresh time.

The first version took an afternoon. The maintenance cost is roughly zero, because there is no Chromium and no Figma file to keep in sync. If you're staring at a Figma folder with five subfolders right now, this is the half-day refactor worth doing.

Top comments (0)