Read & write build-time content for Eleventy from Netlify Blobs, with a TTL-based local cache for fast, offline-tolerant builds.
Content lives in a Netlify Blobs store — written by whatever writes it: a scheduled function, a form handler, a webhook — & is read at build time through a local cache that keeps builds fast & survives a bad network.
| Source of truth | A Netlify Blobs store |
| Read path | The local cache while the entry is inside its TTL, otherwise the store |
| After a fetch | The result is cached, so the next build can run offline |
| If the store is unreachable | An expired entry is served with meta.stale set |
The plugin never reads Eleventy's data directory. Anything you want in git
belongs in _data/, which Eleventy already loads on its own.
Every stored value uses a { meta, ...content } envelope, & every read returns
that whole envelope, so a template destructures metadata & content together:
{% if activity.meta.error %}
<p>Activity unavailable.</p>
{% else %}
<p>Updated {{ activity.meta.generatedAt | generatedAt }}</p>
{% if activity.meta.stale %}<p>Showing the last cached copy.</p>{% endif %}
{% endif %}Reads & writes never throw. A read that fails resolves to
{ meta: { name, store, source, error } }; a write resolves to
{ statusCode, body }. A template renders a fallback instead of failing the
build.
yarn add @allons-y/eleventy-plugin-netlify-data-storeRequires Node 22.12+ & Eleventy 3.0+.
// eleventy.config.js
import netlifyDataStore from "@allons-y/eleventy-plugin-netlify-data-store";
export default function (eleventyConfig) {
let { readBlob, listData } = eleventyConfig.addPlugin(netlifyDataStore, {
immediate: true,
storeName: "content",
ttl: "1h",
});
eleventyConfig.addGlobalData("activity", () => readBlob("activity"));
}immediate: true runs the plugin during the config file rather than after it,
which is what makes the destructuring above work. Without it, reach for
eleventyConfig.netlifyDataStore from inside a callback, where the plugin has
already run:
eleventyConfig.addPlugin(netlifyDataStore, { ttl: "1h" });
eleventyConfig.addGlobalData("activity", () =>
eleventyConfig.netlifyDataStore.readBlob("activity")
);Outside Eleventy — in a scheduled function, say — build a store directly:
import { createDataStore } from "@allons-y/eleventy-plugin-netlify-data-store";
const { writeBlob } = createDataStore({ storeName: "content" });
export default async () =>
writeBlob("activity", {
meta: { generatedAt: new Date().toISOString() },
entries: await fetchEntries(),
});NETLIFY_SITE_ID & NETLIFY_TOKEN must be set for any read or write that
reaches Netlify Blobs through the default @netlify/blobs factory. Missing
credentials are a configuration error:
getStore throws, & the calling readBlob/writeBlob converts that into the
usual envelope or { statusCode: 500 } so the build still completes. The
reason is always logged.
A read served from a warm cache needs no credentials at all — which is what lets a build run offline.
A custom getStore factory skips the check: it owns its own
configuration. siteID & token are passed through when set, & are
undefined otherwise.
| Function | Returns |
|---|---|
readBlob(name, storeName?) |
The full { meta, ...content } envelope, or { meta: { error } } |
writeBlob(name, data, storeName?) |
{ statusCode, body } |
listData(storeName?) |
string[] of blob keys — the local cache's keys in development |
formatGeneratedAt(value) |
A formatted meta.generatedAt, or "" when missing or unparseable |
readBlob resolves in this order:
- The local cache, when the entry exists & is younger than
ttl. - Netlify Blobs otherwise — & the result is written into the cache.
- The stale cache, if the store could not be reached &
staleIfErroris on. The envelope comes back withmeta.stale&meta.cacheAgeadded, and the originalmeta.generatedAtintact.
writeBlob writes to the store & refreshes the cached copy, so a read that
follows it sees the new value immediately.
eleventyConfig.addPlugin(netlifyDataStore, {
storeName: "content", // default Netlify Blobs store
cacheDir: ".netlify/blobs-cache", // local, uncommitted cache
ttl: "1h", // how long a cached entry stays fresh
staleIfError: true, // serve an expired entry if the store is unreachable
siteID: undefined, // defaults to process.env.NETLIFY_SITE_ID
token: undefined, // defaults to process.env.NETLIFY_TOKEN
quiet: false, // suppress per-read logging (errors still log)
locale: "en-GB", // for formatGeneratedAt
dateFormat: undefined, // Intl.DateTimeFormat options for formatGeneratedAt
filterName: "generatedAt", // filter to register; false to skip
getStore: undefined, // advanced: supply your own store factory
});Accepts the same duration strings as eleventy-fetch — "30s", "30m",
"1h", "7d", "2w", "1y" — plus "*" to never expire & 0 to refetch on
every build. A raw number is milliseconds. An unparseable value throws at
registration rather than mid-build.
The cache applies to every build, not just local development: CI & production
builds read it too, which cuts Blobs calls & build time. NODE_ENV plays no
part.
On by default. When the entry has expired and the store cannot be reached —
an outage, a missing credential, a deleted blob — the expired copy is served
rather than failing, carrying meta.stale: true & meta.cacheAge in
milliseconds so a template can say how old it is. Set it to false to never
serve data past its TTL; reads then return the usual error envelope.
Replaces the default @netlify/blobs factory. It is called once per store name
with { name, siteID, token } & must return an object with the methods the
plugin uses: get(key, { type: "json" }), setJSON(key, value), & list().
Use it to read from somewhere other than a Netlify Blobs store the build can
reach directly, such as an HTTP endpoint:
eleventyConfig.addPlugin(netlifyDataStore, {
getStore: ({ name }) => ({
async get(key) {
let response = await fetch(`https://example.com/.netlify/functions/blobs/${name}/${key}`);
return response.ok ? response.json() : null;
},
}),
});With a custom factory, credentials are optional, log lines name a custom
store, & a read's error envelope carries meta.source: "custom" instead of
"blobs".
cacheDir holds a mirror of the store at
<cacheDir>/<storeName>/<name>.json. Each file wraps the value with the time
it was fetched:
{
"cachedAt": "2026-09-05T06:15:00.000Z",
"value": { "meta": { "generatedAt": "…" }, "entries": [] }
}It is a cache, not a fixture: written by the plugin, never committed, & safe to
delete. Add it to .gitignore:
.netlifyWarming it takes one build with credentials present; every build after that runs offline until the TTL lapses.
sample/ is a minimal Eleventy site that reads entirely from the cache, with
no network access & no credentials. It points cacheDir at a committed fixture
& pins ttl: "*" so the fixture never expires:
yarn sampleyarn test # ava
yarn lintNetlify Blobs is never contacted: tests pass a fake store factory through the
getStore option.
Releases are managed by Changesets. When you make a change that affects how the plugin behaves for consumers, add a changeset:
yarn changesetPick the bump type when prompted, write a short but descriptive summary, & commit
the generated file in .changeset/ alongside your code:
- patch — bug fixes
- minor — new, backwards-compatible features
- major — breaking changes
On every push to main the Release workflow opens or updates a "Version
packages" PR; merging it publishes to npm & tags the release. Don't edit
version in package.json or the released sections of CHANGELOG.md by hand.
Please be sure that changes are written for a human reader rather than a short commit message. Changes that don't affect consumers (docs, tests, CI, internal refactors) don't need a changeset.
Full details are in CONTRIBUTING.md.
Contributions are welcome & appreciated! Please open an issue (before submitting a pull request) if you find a bug, have a feature request, or have a question. Be sure to follow the contribution guidelines and code of conduct but most importantly, be excellent to each other. 💖
This project is licensed under the MPL-2.0 License - see the LICENSE file for details. This means you can use this freely, modify as needed; changes to MPL-licensed files should be shared back under the same license.
If you find this plugin useful and would like to buy me a coffee/beer as a small thank you, I would greatly appreciate it!