How-to › Move data in and out

How to load API data into Observable Framework with a data loader#

Write a data loader that fetches a paginated API under Node at build time with a key from the environment, and read its JSON on the page with FileAttachment.

Audience
Data analyst
Level
intermediate
Topic
Load API data into notebooks and DataFrames
Languages
JavaScript
Verified

Your team wants the orders dashboard to show yesterday’s numbers from the billing API. The API wants a bearer key on every call, and the dashboard is a static site. The first draft put the key in a fetch call on the page, where anyone with the view-source shortcut can read it. The second draft took the key out, and the browser reports a CORS error for every call instead.

What you get

You will end up with a data loader that pulls a paginated API at build time, with a key the browser never sees, and a page that reads it as a file. This is for you if your dashboard’s API needs a key.

Short answer

Put a Node script at src/data/orders.json.js that reads the API URL and key from process.env, follows the pagination, logs to stderr, and writes one JSON document to stdout. Reference it from the page as FileAttachment("data/orders.json").json(). The build runs the loader, hashes the output into dist/_file, and ships the page without the key. A fetch in the page itself is always fresh, but every visitor receives the key.

You will need

Node 20 or later, Observable Framework 1.13, and an API key readable from an environment variable. Verified 2026-10-21 against Node 22.22.0 and @observablehq/framework 1.13.4. The sample pins that version in its package.json, so npm ci installs the one dependency, and it builds against a fake orders API on 127.0.0.1 that wants a bearer key and pages its results. The key is read with process.env, which the loader inherits from whatever shell or CI job runs the build.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
Client-side fetch inside the pagePublic data, or data the visitor may see with credentials of their ownA key in the HTML for anyone to copy, a CORS policy the API must grant your origin, and a request per visitor per loadThe API needs a key the visitor must not hold, or it sends no CORS headers
Observable Framework data loaderDashboards, where a snapshot computed at build time is fast and the key stays in the build environmentData as old as the last build, a build to schedule whenever it should refresh, and a cache that reuses old output until it is clearedNumbers that must be live to the minute, or data that differs per visitor
Observable Notebooks 2.0 HTML notebookPersonal exploration, where a cell does await fetch(...) while you look at the data in a local previewCode that runs in the browser, so a key in a cell is a key in the file you may later share, with no build step to hide it inAnything you publish, where the Framework loader keeps the key out of the page

Freshness and secrecy pull in opposite directions. The loader runs where the key lives and produces a file, so the browser gets data without a credential, at the price of data that is as old as the last build. A fetch in the page is as fresh as the last reload, and whatever it needs to authenticate ships with the page. The notebook suits finding out what the data looks like,1 and stops suiting the moment the file is shared with a key in it.

Write the loader where the key lives

A data loader is an ordinary program whose stdout becomes a file. This one reads the API URL and the key from the environment, follows the pagination, and writes the orders as one JSON array.

const base = process.env.API_URL;
const token = process.env.API_TOKEN;
if (!base || !token) {
  console.error("orders.json.js: API_URL and API_TOKEN must be set");
  process.exit(1);
}

const orders = [];
let next = "/orders?page=1";
while (next) {
  const response = await fetch(base + next, { headers: { authorization: `Bearer ${token}` } });
  if (!response.ok) {
    console.error(`orders.json.js: ${next} answered ${response.status}`);
    process.exit(1);
  }
  const page = await response.json();
  orders.push(...page.data);
  console.error(`orders.json.js: ${next} gave ${page.data.length} orders`);
  next = page.next;
}

process.stdout.write(JSON.stringify(orders));

Two rules shape it. Every log line goes to console.error, which is stderr, because Framework’s data loader documentation says that anything logged to stdout is included in the output file and sent to the client. And every failure ends in process.exit(1). The same page says a loader that fails must return a non-zero exit code, and that empty output is not by itself treated as an error. A loader that swallows a 401 and exits 0 publishes an empty dashboard with a green build beside it.

node demo.mjs
the loader on its own
  exit 0, stdout 527 bytes, 1 line
  stdout parses as JSON: 7 orders
  stderr: orders.json.js: /orders?page=1 gave 3 orders
  stderr: orders.json.js: /orders?page=2 gave 3 orders
  stderr: orders.json.js: /orders?page=3 gave 1 orders

Run on its own, the loader is a script like any other: three requests, one line of JSON on stdout, and the progress on stderr where the build log will show it.

Reference the file and let the build run the loader

The page asks for a file that does not exist in the source tree.

const orders = FileAttachment("data/orders.json").json();

Framework resolves the path relative to the page, finds no data/orders.json, and looks for a file of the same name with a second extension, which is how orders.json.js becomes the loader for orders.json.2 The argument has to be a string literal, because the build finds loaders by reading the page rather than running it,3 and only files a page references this way are built. The config states the source root, which stops the build warning that root is missing, and names the output directory beside it.4

export default {
  root: "src",
  output: "dist",
  title: "Orders",
};

The demo clears the cache and the output, sets API_URL and API_TOKEN, and runs observable build as a child process with the build’s own log sent to stderr.

node demo.mjs
observable build
  pages: index.html, live.html
  data file: dist/_file/data/orders.79f00478.json, 7 orders
  index.html references _file/data/orders.79f00478.json: true
  cache written: true
  requests the API saw during the build: 3

The loader ran once, under the build, against the API: three requests, the whole of the build’s traffic to the API. Its output landed in dist/_file/data/ under a name that carries a content hash, so a changed snapshot gets a changed URL and a visitor’s cache cannot serve the old one. The same output was written to src/.observablehq/cache/. A second build would read it from there without running the loader, which the pitfall below turns on.

Prove the key is in neither the page nor the file

A second page in the sample does what the first draft did and fetches from the page itself, with a placeholder where a key would be.

const token = "sk_live_PLACEHOLDER_do_not_do_this";
const orders = await fetch("https://api.example.com/orders", {
  headers: { authorization: `Bearer ${token}` },
}).then((response) => response.json());

Framework compiles a page’s code blocks into the page’s HTML, so a literal in a block is a literal in dist/live.html. The loader’s key, by contrast, exists only in the process that ran the build.

node demo.mjs
where the token is
  "secret-orders-token" in dist/index.html: 0, in the data file: 0
  "secret-orders-token" in all 7 files under dist/: 0
  "sk_live_PLACEHOLDER" in dist/live.html: 1

The count is the verification. The key the build used appears nowhere in the output. The key the page used appears exactly where every visitor can read it. Pointed at the fake API, the second page would also meet CORS in a browser, because the fake sends no Access-Control-Allow-Origin header, and most internal APIs send none either. The loader runs under Node, where CORS does not apply.

Check it worked

The fourth test runs a clean build and asserts on the output tree rather than on the loader alone.

node --test loader.test.mjs
1..4
# tests 4
# suites 0
# pass 4
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 3388.53926

# pass 4 is the line to look for. The fourth test checks that dist/_file/data/ holds an orders.<hash>.json with all seven orders and that the cache was written. It then checks that the build’s token is in none of the files under dist and the placeholder is in live.html once.

When it goes wrong

The dashboard shows nothing and the build was green. A console.log in the loader went to stdout, which is the file.

node pitfall.mjs
console.error for progress (src/data/orders.json.js)
  stdout starts: "[{\"id\":\"ord_1001\",\"placed\":\"2026-03-02\","
  JSON.parse(stdout): 7 orders
console.log for progress (chatty-loader.mjs)
  stdout starts: "fetched /orders?page=1: 3 orders\nfetched"
  JSON.parse(stdout): SyntaxError: Unexpected token 'e'

The chatty loader exits 0, so the build caches and serves its output, and the page’s .json() call fails in the browser instead. Log to stderr, and test the loader’s stdout with JSON.parse before the build does.

The dashboard shows last week’s numbers after a build. Framework’s cache under src/.observablehq/cache is read during a build whenever the output is present, with no check of modification times. A build on a machine that kept the cache never runs the loader. Delete the cache before a build that must refresh, as build.mjs does, or keep the cache out of CI.

The build fails on a file the project never mentions. Inside a larger repository, the bundler walks up from the project looking for a tsconfig.json. This sample’s first build died on the site’s own, which extends a package the sample does not install. A tsconfig.json of the project’s own, even a small one, stops the walk there.

When not to do this

Do not use a loader for data that must be live or that differs per visitor. The file is a snapshot taken at build time, the same for everyone. A dashboard that promises the current minute needs a fetch at load time against an API designed to be called from a browser, with its own short-lived credentials.

Do not put a key in a page block because the API allowed your origin. CORS decides which pages may call the API; it does nothing to hide what those pages contain, and the key is in the HTML of every visitor who loads the page.

Do not let the notebook become the publication. The HTML notebook format is made for editing and sharing, and a cell that fetched with a key during exploration carries that key into every copy. Move the fetch into a loader before the notebook leaves your machine.

Do not let a loader exit 0 on a failure it noticed. An empty or partial file with a zero exit code is cached and served, and the build passes. Framework warns about an empty file in the build log but cannot tell a partial file from a complete one.

Last verified

Verified 2026-10-21 against Node 22.22.0 and @observablehq/framework 1.13.4. Every output block is what the command preceding it printed, against the fake orders API in the code directory. The build resolves the versions of Framework’s own npm: imports over the network, and that is the only traffic that leaves the machine. The Notebook Kit row is read from its documentation and was not run, because its cells execute in a browser.

Footnotes

  1. The notebook file format is HTML with one script element per cell. The Notebook Kit documentation asks that each cell’s source use four spaces of indentation, which are trimmed when the notebook is parsed, and adds that this is likely to become contextual. To an HTML parser the indentation inside a script element is text like any other, so the four spaces are a rule the format lays over HTML. The same page explains the choice of HTML: editors and diffs already handle it. ↩︎ Back to text

  2. The routing is by second extension, and the list runs from .js and .ts through Python, R, Rust, Go, Java, Julia, PHP, and shell to .exe for an arbitrary executable. The documentation adds that the first extension does not affect the generated snapshot and that the loader is solely responsible for producing the expected output. The name orders.json.js is a promise about the output that only its author keeps. ↩︎ Back to text

  3. The files documentation says FileAttachment can only be passed a static string literal, and that constructing a dynamic path is invalid syntax. The reason is given in the next sentence: static analysis is used to invoke data loaders at build time. A function that refuses a variable as its argument is unusual, and here it is the mechanism by which a build knows what to build before any code has run. ↩︎ Back to text

  4. The configuration reference gives root a default of src and notes that before version 1.7.0 the default was docs. A default that moved once is why the build warns when the option is unstated, and why a project that states it will read the same way after the next move. ↩︎ 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.