Let each product tell your studio site where it stands

A studio site goes stale the moment a product ships. Ours reads each product's state, screens and sketch from the product's own site, so it can't.

By Matthew Kay//5 min read

A studio's own site goes out of date the day one of its products changes, because the facts about each product are copied into it by hand. We stopped copying. Each product's site now publishes a small studio.json about itself and draws its own sketch on request, and mgkcodes.com reads both, so a store launch or a redesign shows up here within the hour with no edit.

What went stale?

The home page shows each product being built in five stages: a sketch of its page, the design, the code, the live site, then where it is out. The first version did that with copies.

  • A hand-built replica of each product's hero, rebuilt from the product's repo in its own fonts and photographs, so the design stage matched the live page.
  • A status line and store links typed into the site's own data file.
  • Copies of each app's store screenshots for the phone that builds alongside the web page.
  • A check script that fetched the live pages and listed the words and pictures that no longer matched.

The replicas were built on 5 October. A day later the page was wrong in a way none of that could catch. Run had gone live on Google Play, and the studio site still said MGKFitness was on a "Waiting list" and "Coming to iPhone and Android", because those words were typed into its data file before the launch. The check script only compared the replicas with the live pages, and the product's own landing page still said "Waiting list" too, so it passed. A person reading the page noticed. That is the problem in miniature: every copy needs someone to remember that it exists, every time a product moves.

What does each product publish?

One file, at a fixed address on its own site. This is MGKFitness's, abridged:

{
  "studio": 1,
  "status": "Run on Google Play",
  "live": true,
  "stores": [
    { "platform": "iPhone", "store": "App Store" },
    { "platform": "Android", "store": "Google Play", "href": "https://play.google.com/store/apps/details?id=com.mgkcodes.fitness.run" }
  ],
  "app": {
    "screens": [{ "src": "/screens/run-record.png", "label": "Run" }],
    "code": ["Scaffold(", "  body: Stack(", "..."]
  },
  "source": { "open": false, "repository": "https://github.com/MGKCodes/mgk-fitness", "branch": "develop", "signOff": true }
}

The important part is where it comes from. It isn't written by hand on the product side either. MGKFitness already kept its store links in one file, links.ts, with a comment that going live "is an edit to this file and nothing else". The route that serves studio.json builds the status line from that same file. When the iPhone link is filled in, the status changes from "Run on Google Play" to cover both stores, and the studio site follows. frunt does the same from its brand.ts.

The source block works the same way. MGKFitness is going open source, and the studio's projects page has a "Work on MGKFitness" section with Open in VS Code, setup and pull request steps. It only appears while open is true, so making the repository public stays a one-line change in the product, not a second job here.

How does the live page draw its own sketch?

The design stage is easy to keep true: it is the live page. The sketch is harder, because a sketch of a page has to match its layout, and the layout changes when the product does. So the product draws it.

Each product's landing page carries a small script that listens for messages from mgkcodes.com while it is framed there. It answers only that origin, and does nothing when the page is opened on its own:

const STUDIO = new Set(["https://mgkcodes.com", "https://www.mgkcodes.com"]);
 
window.addEventListener("message", (event) => {
  if (!STUDIO.has(event.origin) || event.source !== window.parent) return;
  if (event.data?.studio !== 1) return;
  show(event.data.stage); // "blank", "sketch" or "page"
  window.parent.postMessage({ studio: 1, ready: true, outline }, event.origin);
});
// Abridged. The page also says hello first, in case the studio site
// spoke before the script was listening.

The sketch itself is generic CSS over the page's real markup. Text goes transparent and keeps a one-pixel underline, so every line of words becomes a hairline where the words will be. Anything with a fill gets a dashed edge. Pictures and the canvas go. A short delay per element, in reading order, draws it piece by piece:

html[data-studio="sketch"] body * {
  color: transparent !important;
  background: transparent !important;
  text-decoration: underline 1px rgb(91 138 203 / 0.75) !important;
  transition-delay: var(--studio-at, 0ms) !important;
}

Because it is the product's own markup, the sketch lines up with the design stage at any window size, and it changes when the page does. The same script sends the page's first screen as a short outline of its markup (landmarks and the words in them), which the build stage types out. When MGKFitness changes the words in its hero, the code on the studio site changes with them.

What does the studio site do with it?

It reads every product's file on the server and rebuilds the page at most hourly. In Next.js that is one line on the page and a revalidated fetch:

export const revalidate = 3600;
 
const res = await fetch(new URL("/studio.json", product.site.url), {
  next: { revalidate: 3600 },
});

Anything malformed is dropped rather than shown, and a product that doesn't answer gets its card without a status, not a stale one. The app screens come from the product's site too. A small route resizes each one for the phone, and draws the sketch of the first screen on the server by edge detection, so the phone's sketch is always the real screen in hairlines:

sharp(input)
  .resize({ width: 450 })
  .greyscale()
  .blur(0.7)
  .convolve({ width: 3, height: 3, kernel: [-1, -1, -1, -1, 8, -1, -1, -1, -1] });
// The edge strength becomes the alpha of a blue layer over the blueprint.

Two details matter in practice. A product can refuse to be framed: MGKFitness only admits mgkcodes.com, so on localhost and preview deploys its window shows a link and a stand-in sketch instead. And the route that fetches screens only fetches from the products' own hosts, so it can't be pointed anywhere else.

What did it cost, and what did it save?

The change removed 814 lines from the studio site: five replica files, their content file and the check script. Each product gained about 290 lines: the route, the script and its CSS. The contract itself is one documented file on the studio side.

The real saving is the part that doesn't show in a line count. A product team never has to remember the studio site exists. The studio's social scheduler now works the same way: it publishes what it posted at its own address, and the home page reads that instead of keeping a list.

The short version

  • Facts copied into a portfolio go stale the day a product ships. Ours did within a day.
  • Let each product publish a small JSON file about itself, built from the data it already keeps.
  • Let the live page draw its own sketch on request, over its real markup, answering only your origin.
  • Read it all on the server and rebuild hourly; drop anything malformed instead of showing it.
  • When a product doesn't answer, show less, not something stale.
Next.jsArchitecturePortfolio sites

02/More build notes

More build notes