Most Notion templates are built by hand: click a database into existence, add properties, drag views around, copy the page. That works once. It does not work when you want to rebuild it after a Notion change, review exactly what it will create, or ship several templates that share a style.
So I build mine as code. Each template is a small Node.js project that generates a list of Notion API calls, lets me read them, and only then pushes them. This post walks through the design using one of them, an Architecture Snippet Vault: a Snippets database and a Collections database linked both ways, six views, a dashboard page and sample content.
Two steps, and a review in between
The script works in two steps:
-
Local review. It writes the exact API payloads to
schema.jsonand the page text tolayout.md. No token and no network needed. -
Push. It shows what it will create, asks for confirmation, and on
yesruns the files on disk through the official@notionhq/client.
The second point matters. What gets pushed is not regenerated in memory. It is the file I (or a buyer) just edited, so a changed property name or a deleted view in schema.json is exactly what reaches Notion.
| Command | Regenerates files | Prompts | Pushes |
|---|---|---|---|
npm run review |
yes | no | no |
npm start |
yes (overwrites edits) | yes | on yes
|
npm run push |
no | yes | on yes
|
The confirmation is a plain yes. Anything else exits without contacting Notion.
An allowlist instead of trust
schema.json is an ordered list of API calls (33 of them for this template). A file that tells a script what to call is a file that could call anything, so the runner only accepts a fixed set:
// The only API calls schema.json may make. An edited or foreign schema cannot call anything else.
const CALLS = {
"pages.create": (notion, payload) => notion.pages.create(payload),
"pages.updateMarkdown": (notion, payload) => notion.pages.updateMarkdown(payload),
"databases.create": async (notion, payload) => { /* ... */ },
"dataSources.update": (notion, payload) => notion.dataSources.update(payload),
"views.create": (notion, payload) => notion.views.create(payload),
// plus retrieve/list/update helpers
};
for (const [i, step] of schema.steps.entries()) {
if (!CALLS[step.call]) throw new Error(`schema.json step ${i + 1}: unsupported call "${step.call}".`);
}
The whole schema is validated before the first request is sent, so a bad step fails early instead of half way through a push.
IDs that do not exist yet
The awkward part of building Notion structures through an API is that IDs only exist after something is created: the page, the database, every property. A relation needs the other database's ID, and a view refers to properties by ID.
The generated steps solve this with placeholders and a capture map. A step says which fields of its response to remember, and later steps use them as {{...}}:
async function runStep(notion, step, ctx, chunks) {
const response = await CALLS[step.call](notion, resolve(step.payload, ctx, chunks));
for (const [key, dotted] of Object.entries(step.capture ?? {})) {
const value = getPath(response, dotted);
if (value == null) throw new Error(`The ${step.call} response has no "${dotted}" to keep as ${key}.`);
ctx[key] = value;
}
// View configurations refer to properties by ID, which Notion only reveals after creation.
if (step.captureProperties) { /* remember PROPERTY_ID:<database>:<name> */ }
return response;
}
If a placeholder has no value, the error says which step should have provided it and that it "failed, was skipped, or comes later in schema.json". The message points straight at the step that is missing something, instead of leaving you to guess.
Order matters too. Databases are appended to the end of a page, so the text around them has to be inserted in between. layout.md carries two <!-- database: ... --> markers, and the script splits the text at them into three chunks and interleaves them with the database creation.
A formula that keeps itself honest
The vault tracks whether each snippet is still trustworthy. A Review Health formula labels it:
const REVIEW_HEALTH_FORMULA =
'if(prop("Status") == "Deprecated", "⚫ Deprecated", ' +
'if(empty(prop("Last Reviewed")), "⚪ Not reviewed", ' +
'if(dateBetween(now(), prop("Last Reviewed"), "days") > 180, "🔴 Stale", "🟢 Fresh")))';
A "Review Queue" view is built on top of it, so the next thing to re-check is easy to find.
Formulas that depend on now() create a problem for templates: sample data ages. If the sample snippets carry fixed review dates, a template built once slowly turns every sample red. I handle it two ways:
- Sample dates are set relative to the day the build runs, so a fresh build always shows a mix of Fresh and Stale.
- A
refreshcommand re-dates the samples later. It lists the dates it will set, asks foryes, and only touches pages whose title matches a sample in the seed file, so anything the user added is left alone.
Things I was honest about in the docs
-
Views are marked
optional. If Notion rejects a view, the script warns and carries on, because all the data is already there. -
Running the push twice creates a second copy. The IDs of everything created are saved to
push-result.json, and the README says to delete the first copy before re-running. -
The review step is not decoration. The README tells you to open
schema.jsonandlayout.mdand edit them. The tool is built around the idea that you read before you push.
What I would tell my past self
- Treat a template as a build artifact. Keep the source (seed data, schema builder, layout text) and generate the rest.
- Put a human-readable review step between generating and sending.
- Restrict what a data file can make your script do.
- Anything that depends on
now()needs a plan for sample data that ages. - When an API hands out IDs late, design placeholders and capture rules up front.
Talk to me
I build developer tools, automation and full-stack products, and I take freelance projects: mahmoud-farouk-portfolio.vercel.app.
If you build Notion templates, do you generate them or click them together? I am curious where the API still makes you fall back to the UI.


Top comments (0)