How-to › Document and support developers

How to build a sample app that developers fork instead of ignore#

Build one sample app with a key, a list, a form, and real errors, name its owner and omissions in the README, and link it only to hosts that can run it.

Audience
DevRel
Level
intermediate
Topic
Run developer relations
Languages
TypeScript and JavaScript
Verified

Your API has a samples repository with eleven snippets and no app a reader can run. A developer who wants to see a key, a list, and a form working together finds a gallery and a framework she does not use. The README assumes a dashboard account she does not have. The repository’s fork count stopped moving a year ago.

What you get

You will end up with one reference app, a README that names its owner and its omissions, a generated open link, and a check that fails a sample nobody owns. This is for you if you run developer relations for an API.

Short answer

Build one app rather than a gallery: one key read from the environment, one list view, one create action, and a message for every way the API can refuse. Put the owner, the dependency bot, and an archive date in the README beside what the app leaves out. Generate its open link, and skip hosts that cannot run it. Then time a stranger from clone to a working page, and treat anything over fifteen minutes as your defect.

You will need

Node 22 or later, a public API with a sandbox or test credential a stranger can get in minutes, and a Git host. Verified 2026-10-21 against Node 22.22.0. The sample is a dashboard for a smart-meter API, run against a stand-in sandbox in the same process, so nothing on this page reaches the network. The hosts and the browser editor are compared from their documentation, not from a deployment.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
Deploy to Netlify buttonA static site, or one whose server code is written as Netlify Functions, including one example in a monorepo, since the button can clone a subdirectoryA netlify.toml in the example’s root to require the variables, and a Netlify account the reader may not holdThe reader wants to run it on a laptop first
GitHub template repositoryOne repository per framework, with Use this template and a clean single-commit startOne repository to own, update, and archive per framework, and a feature that works on a whole repository and never on a folderYou keep every example in one repository
Monorepo of examplesSeveral frameworks sharing one issue tracker, one dependency bot, and one ownerNo Use this template, a clone that brings every example to get one, and relative dependency paths that break in a browser importEach framework has a maintainer of its own
Railway templateA server with a database beside it, with a root directory setting for one example in a monorepoA template composed in Railway’s UI rather than a file in the repository, and a button URL that names a template code rather than your repositoryThe app is static, or the configuration has to live in git
StackBlitz linkA reader who wants to see it run before installing anything, from the repository or one folder of itNode in the browser through WebContainers, a key pasted into a browser tab, and an import that sees only the folder it was givenThe sample needs a native dependency or a database
Vercel Deploy ButtonA framework Vercel builds, with the variables asked for during the clone through env and envDescriptionA parameter list that lives in the page’s generator, so a link typed by hand drops env= and the deploy starts without a keyThe app is not a web deployment

A template repository per framework gives the reader a clean copy and gives you one more repository to own each time, with its own bot, README, and archive date. A monorepo gives you one of each and takes Use this template away, since GitHub applies it to a whole repository. The deploy buttons differ on where the variables are declared: Netlify reads a file in the repository, Vercel reads the link, and Railway reads a template you compose in its UI.

Build one path from a key to a working page

The app is a dashboard for a smart-meter API: one list view, one create action, and the errors in between. The whole login is an API key read from the environment, and loadConfig refuses to start without one, in a sentence that says where a key comes from. MetercoClient turns every refusal the API can send into a message for a stranger holding only the README.

    if (res.ok) return res.json()
    const problem = await res.json().catch(() => ({}))
    if (res.status === 401) {
      throw new ApiError('The API rejected the key in METERCO_API_KEY. Test keys start with sk_test_ and live keys with sk_live_.', { status: 401 })
    }
    if (res.status === 422) {
      throw new ApiError('The API rejected the form.', { status: 422, fields: problem.fields || {} })
    }
    throw new ApiError(`${res.status} from ${method} ${path}: ${problem.message || 'no detail'}`, { status: res.status })

The demo drives the dashboard against a stand-in sandbox through the four paths a reader will hit: no key, a wrong key, a form the API rejects, and a form it accepts.

node demo.mjs
1. no key
   refuses to start: METERCO_API_KEY is not set. Copy .env.example to .env and paste a key from https://dashboard.meterco.example/keys (test keys start with sk_test_).
2. wrong key
   GET / -> 502: The API rejected the key in METERCO_API_KEY. Test keys start with sk_test_ and live keys with sk_live_.
3. the right key, a bad form, then a good one
   GET / -> 200, 2 meters listed
   POST /meters (empty serial, unknown site) -> 422
     serial is required
     site must be one of: hq, depot
   POST /meters (valid) -> 303 to /
   GET / -> 200, 3 meters listed

Every message names the variable to fix or the field to change, in place of a stack trace. The app runs on Node’s standard library, and npm start is node src/server.mjs, so the first run installs nothing. The server reads the .env the README told the reader to copy with process.loadEnvFile when the file exists, so a host that sets the variables itself needs no file.

Write down who owns it and what it leaves out

Two sections of the README do more for the fork count than the code. One says what the app leaves out on purpose, so a reader stops looking for the pagination and copies the shape. The other names a maintainer, a dependency bot, and the date after which the sample is archived if nobody has reviewed it.

## What this app leaves out

- User accounts and sessions. One API key, read from the environment, is the whole login.
- Pagination. The list shows the first page the API returns.
- A database. Nothing is stored except in the API.
- Retries, caching, and a design system. The point is the API calls and their errors.

Copy the shape, not the omissions.

## Who looks after it

- Maintainer: the DevRel samples team, `@meterco/devrel-samples`, named in `.github/CODEOWNERS`.
- Dependencies: Dependabot, monthly, from `.github/dependabot.yml`.
- Review by: 2027-04-01. If nobody has reviewed this sample by then, it is archived and the
  README says so at the top.

The maintainer is a CODEOWNERS entry, so every pull request asks that team for review. The bot is a dependabot.yml with a monthly schedule. The archive date is a sentence, and archiving makes the repository read-only and marks it as no longer maintained. That is more use to a reader than a sample that runs against last year’s API. readme-check.mjs turns the rule into a check a pull request can run.

node readme-check.mjs
ok   a named maintainer in .github/CODEOWNERS
ok   a dependency update bot
ok   a review-by date in the README
ok   a "What this app leaves out" section
ok   every variable in .env.example named in the README
ok   a deploy or open link in the README
ok   266 source lines, within the 400-line reading budget

all 7 checks pass

The last line is the pitfall the brief for this page names: production-grade complexity nobody can read in ten minutes. The check counts the source lines in the repository, tests excluded, and fails past four hundred. The number is a budget rather than a law, and a budget is what stops a sample from becoming a product.

A deploy that does not know about METERCO_API_KEY refuses to start, and its log carries the message from the first path in the demo. The open link is generated from the repository in package.json, and a test fails the build when the README’s link differs from it.

export function buttons(dir = '.') {
  const pkg = JSON.parse(readFileSync(new URL('package.json', pathToFileURL(dir + '/')), 'utf8'))
  const repo = pkg.repository
  const stackblitz = new URL(repo.replace('https://github.com/', 'https://stackblitz.com/github/'))
  stackblitz.searchParams.set('startScript', 'start')
  stackblitz.searchParams.set('title', 'Meterco dashboard')
  return { stackblitz: stackblitz.toString() }
}
node buttons.mjs
stackblitz https://stackblitz.com/github/meterco/meterco-dashboard?startScript=start&title=Meterco+dashboard

StackBlitz takes a startScript and opens the terminal for the key.1 Railway has no link to generate, because its template is composed in its UI, where the required variables and the root directory are set.2

The dashboard runs only where a Node server can. StackBlitz runs it as it is, and so does Railway, which injects PORT and routes to it from outside the container. The README has no button for Netlify or Vercel, because Netlify runs server code as Functions and Vercel deploys a named server framework or functions, not a bare node:http server.

The monorepo question is decided by the same documentation. StackBlitz imports one folder of a repository and sees nothing outside it, so an example that depends on a sibling package by relative path breaks in the browser. Netlify clones a subdirectory and Railway sets a root directory, so both serve a monorepo. GitHub’s Use this template button and its single-commit copy3 apply to a repository, so one template per framework is the price of that button.

Check it worked

node --test app.test.mjs
1..12
# tests 12
# suites 0
# pass 12
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 376.977739

Tests six and seven are the ones to keep. The sixth feeds a sample with no owner through the check and asserts six failures. The seventh asserts that the README carries exactly the links the generator writes, so a link edited or added by hand fails the build. Three more run the start script as a host does, from a directory with no .env file.

When it goes wrong

The sample has readers and no forks, and nobody can say who looks after it. Run the check against it. This one is a demo with a README of three lines and a package.json of three frameworks.

node readme-check.mjs fixtures/unowned
MISSING a named maintainer in .github/CODEOWNERS: add a line such as "* @org/team"
MISSING a dependency update bot: add .github/dependabot.yml or renovate.json
MISSING a review-by date in the README: name the date after which the sample is archived
MISSING a "What this app leaves out" section: say what was left out on purpose
MISSING every variable in .env.example named in the README: add a .env.example
MISSING a deploy or open link in the README: add a Deploy to Netlify, Vercel, or StackBlitz link
ok   4 source lines, within the 400-line reading budget

6 of 7 checks failed in fixtures/unowned

Each line is a fix, and the first is the one to make: a sample with no named owner is archived or adopted, never left.

The stranger takes forty minutes. Sit beside the next one with a stopwatch and write down where the minutes go. In most samples they go to three places. A credential that needs an account approval, a framework toolchain to install before the first page, and a database the sample wanted for one table. Cut the step, or move it off the first path, and time the next stranger. The README promises fifteen minutes because a run was timed, and a promise with no stopwatch behind it is a guess.

A deploy from the Railway template fails, and its log says METERCO_API_KEY is not set. The template was composed without the variable, so the deploy started with no key. Add both variables from .env.example on the template’s Variables tab, where Railway’s guide puts the variables a deploy requires.

When not to do this

Do not build the sample on the framework your team is most excited about this quarter. The reader forks the sample for your API, not for the framework, and every framework-specific line is a line they delete first. Node’s standard library is the dull choice and the one that reads in ten minutes.

Do not let the sample grow. A sample that gains authentication, a job queue, and a design system has become a product with no customers, and the four-hundred-line budget exists to stop that before it starts.

Do not publish a sample without an owner, a bot, and an archive date. An unowned sample stops working on the first breaking change to your API, and it keeps ranking in search for a year after that. Archive it on the date you wrote down, and the README says so at the top.

A time you have not measured is not a promise. Fifteen minutes with a stranger and a stopwatch is the test, and the number in the README is the result of the last run, never the goal.

Last verified

Verified 2026-10-21 against Node 22.22.0. Every output block is what the command preceding it printed, run in the page’s code directory against a stand-in sandbox in the same process. No deployment was made to Netlify, Vercel, or Railway, and no StackBlitz import was opened: the StackBlitz link was generated and checked for the parameters its documentation names, not followed. What each host runs is read from its documentation. The fifteen-minute test is a stopwatch and a stranger, and it was not run here.

Footnotes

  1. StackBlitz asks maintainers to keep the lockfile in the repository, because a package manager that finds package-lock.json skips dependency resolution, which it says takes a significant amount of time during installation. The file that pins a sample’s dependencies for reproducibility also decides how long a reader in a browser tab waits for the first page. ↩︎ Back to text

  2. Railway’s templates can declare a variable as ${{secret()}}, a function run when the template is deployed that generates a random secret of 32 characters by default. Of the hosts compared here, it is the one place a credential is minted by the host rather than pasted by the reader. ↩︎ Back to text

  3. GitHub’s own comparison of the two routes notes that a fork carries the parent’s whole history while a repository created from a template starts with a single commit. It adds that commits to a fork stay off the contributor’s contributions graph while commits to a templated copy appear on it. The template route hands the developer a green square, and the fork route hands them your history. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.