# 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.

Source: https://voxgig.com/howto/build-a-sample-app-developers-fork

- Audience: devrel
- Level: intermediate
- Languages: typescript, javascript
- Verified: 2026-10-21
- Published: 2026-10-19

## 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

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [Deploy to Netlify button](https://docs.netlify.com/deploy/create-deploys/#deploy-to-netlify-button) | A static site, or one whose server code is written as Netlify Functions, including one example in a monorepo, since the button can clone a subdirectory | A `netlify.toml` in the example's root to require the variables, and a Netlify account the reader may not hold | The reader wants to run it on a laptop first |
| [GitHub template repository](https://docs.github.com/en/repositories/creating-and-managing-repositories/creating-a-template-repository) | One repository per framework, with Use this template and a clean single-commit start | One repository to own, update, and archive per framework, and a feature that works on a whole repository and never on a folder | You keep every example in one repository |
| [Monorepo of examples](https://docs.npmjs.com/cli/v10/using-npm/workspaces/) | Several frameworks sharing one issue tracker, one dependency bot, and one owner | No Use this template, a clone that brings every example to get one, and relative dependency paths that break in a browser import | Each framework has a maintainer of its own |
| [Railway template](https://docs.railway.com/templates/publish-and-share#deploy-on-railway-button) | A server with a database beside it, with a root directory setting for one example in a monorepo | A 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 repository | The app is static, or the configuration has to live in git |
| [StackBlitz link](https://developer.stackblitz.com/guides/integration/open-from-github#project-url) | A reader who wants to see it run before installing anything, from the repository or one folder of it | Node in the browser through WebContainers, a key pasted into a browser tab, and an import that sees only the folder it was given | The sample needs a native dependency or a database |
| [Vercel Deploy Button](https://vercel.com/docs/deploy-button) | A framework Vercel builds, with the variables asked for during the clone through `env` and `envDescription` | A parameter list that lives in the page's generator, so a link typed by hand drops `env=` and the deploy starts without a key | The 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.

```js title="src/api.mjs"
    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.

```bash
node demo.mjs
```

```text output
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`](https://nodejs.org/api/process.html#processloadenvfilepath) 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.

```text title="README.md"
## 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](https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners)
entry, so every pull request asks that team for review. The bot is a
[`dependabot.yml`](https://docs.github.com/en/code-security/reference/supply-chain-security/dependabot-options-reference)
with a monthly schedule. The archive date is a sentence, and
[archiving](https://docs.github.com/en/repositories/archiving-a-github-repository/archiving-repositories)
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.

```bash
node readme-check.mjs
```

```text output
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.

## Generate the open link, and deploy where a server runs

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.

```js title="buttons.mjs"
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() }
}
```

```bash
node buttons.mjs
```

```text output
stackblitz https://stackblitz.com/github/meterco/meterco-dashboard?startScript=start&title=Meterco+dashboard
```

StackBlitz takes a
[`startScript`](https://developer.stackblitz.com/guides/integration/open-from-github#launching-a-script-on-project-load)
and opens the terminal for the key.[^1] Railway has no link to generate, because its
[template](https://docs.railway.com/templates/create#configuring-services) 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](https://docs.netlify.com/build/functions/overview/) and Vercel deploys a
[named server framework](https://vercel.com/docs/frameworks/backend) 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](https://developer.stackblitz.com/guides/integration/open-from-github#organizing-the-project-in-your-repository),
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 copy](https://docs.github.com/en/repositories/creating-and-managing-repositories/creating-a-repository-from-a-template)[^3]
apply to a repository, so one template per framework is the price of that button.

## Check it worked

```bash
node --test app.test.mjs
```

```text output
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.

```bash
node readme-check.mjs fixtures/unowned
```

```text output
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.

## Related how-tos

- [Audit a DevRel program in one week](/howto/audit-a-devrel-programme-in-one-week)

- [Choose developer experience metrics for your API](/howto/choose-developer-experience-metrics-for-your-api)

- [Automate dependency updates with Dependabot or Renovate](/howto/automate-dependency-updates-with-dependabot-or-renovate)

## 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.

[^1]: StackBlitz asks maintainers to
[keep the lockfile](https://developer.stackblitz.com/guides/integration/open-from-github#keep-the-package-lock-json-file)
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.

[^2]: Railway's templates can declare a variable as
[`${{secret()}}`](https://docs.railway.com/templates/create#template-variable-functions), 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.

[^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](https://docs.github.com/en/repositories/creating-and-managing-repositories/creating-a-repository-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.