research/managing-template-assets-from-figma

Managing 64 Template Thumbnails and Assets from Figma

Engineering Note · October 30, 2026 · 8 minute read · Figma · Astro · Assets

Managing template assets from Figma comes down to one rule: a single stable slug in a single data file names everything else, the thumbnail file, the page route and the design source, and a script verifies the four stay in agreement. A template gallery looks simple until it has dozens of entries. Ours has 64, each with a thumbnail, a page, a category, and a design source in Figma. This note covers the structure we settled on, a runnable checker you can adapt, and what it printed when we broke things on purpose.

Versions for the runnable parts: Node 22, playwright-core 1.63.0, Chromium 141. The fixture is a throwaway tree built for this note with 64 generated entries, not our real gallery.

What is the data model?

Every template is described by an entry in a single JSON data file. Each entry carries a slug, a title, a category, a thumbnail path, and the Figma page id the design came from. That is the whole record:

{
  "slug": "tpl-07-salon",
  "title": "Template 7",
  "category": "salon",
  "thumbnail": "/templates/thumbs/tpl-07-salon.webp",
  "figmaPageId": "106:43"
}

The slug is the important part. It is a stable key that ties four things together: the data entry, the thumbnail filename, the route, and the design source. When the slug is the same everywhere, finding the thumbnail for a template is a string operation, not a search. Thumbnails live at public/templates/thumbs/ and are named after the slug with a .webp extension. If you ever want to rename something, you rename it in one place and the rest follows. If you let the filename and the route drift apart, you pay for it later.

data/templates.json  --slug-->  public/templates/thumbs/<slug>.webp
        |                                   |
        +--slug--> dist/templates/<slug>/index.html  (built route)
        +--figmaPageId--> the design source in Figma

Why keep the Figma source id in the data?

The Figma page id sits in the data file for traceability. It costs one field per entry and answers a question that otherwise takes real time: where did this design come from? Someone picking up the gallery next year can go from a template on the site straight to its source page, without reading commit history or asking us. If you build something like this, record the source id when you create the entry, not later when you have forgotten where you got it.

How should thumbnails be exported?

Thumbnails are exported from Figma as WebP at one consistent size and format. Consistency matters more than the specific choice. A grid of mixed sizes needs per-image handling in the layout, and a mix of formats means more than one export path to remember. With one size and one format, the export step is mechanical and the gallery markup does not need to know anything about individual images. For a related example of working from Figma to code, see rebuilding a Figma frame as a scaled canvas.

How do you check that the data and the files agree?

We wrote a small script with two jobs: confirm that every entry has a thumbnail file on disk and a built page, and scan the built HTML for broken internal links and assets. Here is a version you can run against any tree shaped like the one above (data/templates.json, public/templates/thumbs/, dist/):

// check-assets.mjs - data-vs-files consistency for a template gallery. No dependencies.
import fs from 'node:fs';
import path from 'node:path';
const root = process.argv[2] || 'site';
const entries = JSON.parse(fs.readFileSync(path.join(root, 'data/templates.json'), 'utf8'));
const problems = [];
const seen = new Set();
const thumbDir = path.join(root, 'public/templates/thumbs');

for (const e of entries) {
  if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(e.slug)) problems.push(`${e.slug}: slug is not kebab-case`);
  if (seen.has(e.slug)) problems.push(`${e.slug}: duplicate slug`);
  seen.add(e.slug);
  if (!e.figmaPageId) problems.push(`${e.slug}: missing figmaPageId`);
  const want = `/templates/thumbs/${e.slug}.webp`;
  if (e.thumbnail !== want) problems.push(`${e.slug}: thumbnail path is ${e.thumbnail}, expected ${want}`);
  if (!fs.existsSync(path.join(root, 'public', want))) problems.push(`${e.slug}: thumbnail file missing (${want})`);
  if (!fs.existsSync(path.join(root, 'dist/templates', e.slug, 'index.html'))) problems.push(`${e.slug}: built page missing`);
}
for (const f of fs.readdirSync(thumbDir)) {
  if (f.endsWith('.webp') && !seen.has(f.replace(/\.webp$/, ''))) problems.push(`${f}: orphan thumbnail, no data entry`);
}

// scan built HTML for broken internal references
let refs = 0;
const walk = (d) => fs.readdirSync(d, { withFileTypes: true }).flatMap((x) => x.isDirectory() ? walk(path.join(d, x.name)) : [path.join(d, x.name)]);
for (const file of walk(path.join(root, 'dist')).filter((f) => f.endsWith('.html'))) {
  const html = fs.readFileSync(file, 'utf8');
  for (const m of html.matchAll(/(?:href|src)="(\/[^"#?]*)"/g)) {
    refs++;
    const target = path.join(root, 'dist', m[1]);
    const ok = fs.existsSync(target) && (fs.statSync(target).isFile() || fs.existsSync(path.join(target, 'index.html')));
    if (!ok) problems.push(`${path.relative(root, file)}: broken reference ${m[1]}`);
  }
}
console.log(`entries: ${entries.length}, internal references scanned: ${refs}`);
if (problems.length) { console.log(`PROBLEMS (${problems.length}):`); problems.forEach((p) => console.log(' - ' + p)); process.exit(1); }
console.log('OK: every entry has a thumbnail, a built page, and no broken internal references');

On the clean 64-entry fixture it prints:

entries: 64, internal references scanned: 256
OK: every entry has a thumbnail, a built page, and no broken internal references

Then we broke the fixture three ways, each time from a fresh copy of the clean tree.

Thumbnail deleted, site rebuilt without it (the file is gone from public/ and from dist/):

entries: 64, internal references scanned: 256
PROBLEMS (3):
 - tpl-07-salon: thumbnail file missing (/templates/thumbs/tpl-07-salon.webp)
 - dist/templates/index.html: broken reference /templates/thumbs/tpl-07-salon.webp
 - dist/templates/tpl-07-salon/index.html: broken reference /templates/thumbs/tpl-07-salon.webp

Orphan export plus a deleted built page (a thumbnail exported with no entry, and one page missing from the build):

entries: 64, internal references scanned: 254
PROBLEMS (3):
 - tpl-12-clinic: built page missing
 - tpl-65-clinic.webp: orphan thumbnail, no data entry
 - dist/templates/index.html: broken reference /templates/tpl-12-clinic/

One slug renamed in the data file only:

entries: 64, internal references scanned: 256
PROBLEMS (4):
 - tpl-20-clinic-v2: thumbnail path is /templates/thumbs/tpl-20-clinic.webp, expected /templates/thumbs/tpl-20-clinic-v2.webp
 - tpl-20-clinic-v2: thumbnail file missing (/templates/thumbs/tpl-20-clinic-v2.webp)
 - tpl-20-clinic-v2: built page missing
 - tpl-20-clinic.webp: orphan thumbnail, no data entry

The first catches the most common failure, which is adding a template to the data and forgetting the image, or exporting an image and forgetting the entry. The second looks at the output the visitor actually receives, not at the source. On our current build the scan found zero broken links or assets. We run it because it is cheap, not because we expect it to fail, and the day it does fail it will say so before a visitor does.

One more observation from the fixture: with only the file deleted from public/ and the old dist/ left in place, the checker reports one problem (the missing source file) but no broken reference, because the stale build output still contains the thumbnail. That is the reason to run the check after a fresh build, not before one.

Why do lazy-loaded thumbnails cause false alarms?

The gallery uses native lazy loading, with loading=“lazy” on the images. That is the right default for a long grid, because the browser only fetches what is near the viewport. It has a consequence for testing, though: an automated check that loads the page and immediately counts images will find many that have not loaded yet, and report them as broken.

We measured it on the fixture’s gallery page (64 lazy images in a four-column grid, 3260px tall, viewed at 1280 by 800). Counting images with complete && naturalWidth > 0:

MomentLoaded of 64
Right after the load event, four runs2, 0, 1, 0
After scrolling the whole page height64

The count right after load varies from run to run, which is itself the warning: a check built on it is flaky as well as wrong. Those are false alarms. The fix is to scroll the page through its full height before counting, so every image has had a chance to enter the viewport:

await page.evaluate(async () => {
  for (let y = 0; y <= document.documentElement.scrollHeight; y += innerHeight / 2) {
    scrollTo(0, y); await new Promise((r) => setTimeout(r, 50));
  }
  scrollTo(0, 0);
});
await page.waitForFunction(() => [...document.images].every((x) => x.complete));

After that, a real broken image is a real broken image. It is a small detail, but it is the difference between a check you trust and a check you learn to ignore. We ran into the same class of problem in testing flows with third-party widgets, where a test sampled state before an asynchronous action finished.

What about fonts and licensing?

One more rule belongs in the process. When a design uses a font that cannot be shipped on the site, we export that text as an image and note it. The thumbnail then looks like the design without the site distributing a font it has no right to distribute. The note matters as much as the export: it tells the next person that the image is deliberate and should not be rebuilt as live text. For fonts we are allowed to ship, self-hosting Google Fonts covers our approach.

The checklist

  • One data file, one stable slug per template.
  • Thumbnails named by slug, exported at one size and format.
  • The source design id stored in the data.
  • Automated checks that scroll before counting lazy images.
  • A check that every entry has a file and a built page, run after a fresh build.
  • Text from unshippable fonts exported as an image, with a note.

None of this is clever. It is a set of small agreements that mean 64 entries behave like one.

Frequently asked questions

How do you manage hundreds of template thumbnails exported from Figma?

Give every template one stable slug in a single JSON data file, and derive the thumbnail filename, the route and the Figma page id from it. Export all thumbnails at one size and format, such as WebP. Then run a script that checks every entry has a file on disk and a built page, so drift is caught automatically.

How do you check that every data entry has a matching image file?

Write a small script that reads the data file, builds the expected thumbnail path from each slug, and checks the file exists, that the built page exists, and that no thumbnail lacks an entry. In our 64-entry fixture it printed one line per problem and exited with status 1 when a thumbnail was removed.

Why do my automated tests report lazy-loaded images as broken?

Images with loading lazy are not fetched until they approach the viewport, so a check that counts images right after page load sees most as unloaded. In four runs on our 64-image fixture, 0 to 2 images had loaded right after load, and all 64 loaded after scrolling the full page height. Scroll first, then count.

What is a good naming scheme for template thumbnails?

Name each file after the template's slug with a fixed extension, under one directory, for example public/templates/thumbs/ followed by the slug and .webp. The slug is then the single key tying the data entry, the file, the route and the design source together, so finding or renaming an asset is a string operation.

What happens if you rename a template slug in only one place?

The checks break in four places at once. In our test, renaming one slug in the data file only produced a thumbnail path mismatch, a missing thumbnail file, a missing built page, and an orphan thumbnail under the old name. The checker listed all four, which makes the inconsistency easy to trace.

More from Research

← All research