How to use Notion as Code
By Flavio Copes
Learn how Notion as Code defines teamspaces, pages, databases, views, entries, and custom agents in TypeScript, then deploys them with the Notion CLI.
Notion just introduced Notion as Code, a new way to define an entire Notion workspace in TypeScript and deploy it through the Notion API.
Not just a page.
You can define teamspaces, pages, databases, database views, entries, and custom agents. You can keep that definition in Git, review changes in a pull request, and apply the same structure to another workspace.
This changes the role of the Notion API.
Until now, using the API usually meant writing an imperative script. Create a database. Save the returned ID. Create some pages. Handle partial failures. Decide what to do when you run the script again.
Notion as Code gives us a declarative layer instead. We describe the workspace we want. The Notion CLI keeps track of the real IDs and updates the same resources on the next run.
It feels a little like infrastructure as code, but for the structure of a company.
Let’s see how it works.
A beta warning before we start
Notion announced the feature as a beta. The official template repository still calls the underlying system alpha and experimental.
Notion also published a How to use Notion as Code guide for the beta.
The public Notion CLI reference does not list the notion-as-code commands yet, either.
This means access is still rolling out. The API can reject a request in some environments. Your installed CLI might not include the command yet.
If this happens, it does not necessarily mean your TypeScript is wrong. You may not have access to the beta yet.
I would also start in a test workspace. The feature creates real teamspaces, databases, pages, and agents. A typo is much less exciting when it happens in an empty workspace.
What Notion as Code is
A Notion as Code project is a small TypeScript project.
The TypeScript file does not call the Notion API while it runs. Calls like notion.teamspace() and notion.database() record intents.
When you build the project, those intents are written to:
dist/intents.json
The Notion CLI reads that file and applies the intents to the workspace.
There are three separate pieces:
- your TypeScript, which describes the desired workspace
- local state, which maps your stable names to real Notion IDs
- the Notion workspace, which contains the actual resources
That separation is the key to the whole system.
The code might call a database projects-database. Notion will give that database a long internal ID. The local state remembers the connection between the two.
When you apply the project again, the CLI finds the existing database and updates it. It does not create another database with the same name.
What it is not
Notion as Code is not a backup of your workspace.
It is also not a live two-way sync between Notion and a Git repository.
It describes the resources included in the project. A person can still open Notion and write inside a page, comment, change a status, and do the normal collaborative work Notion is good at.
The repository is best seen as the source of truth for workspace architecture. It does not have to become the source of truth for every sentence, task, or meeting note.
This distinction matters.
If you put every changing task in TypeScript, your repository will become noisy. If you only put the stable structure in code, the code becomes useful.
My advice is to start with teamspaces, hub pages, database schemas, views, templates, and agents. Let people own the day-to-day content in Notion.
Install the Notion CLI
Notion as Code uses the ntn CLI.
On macOS and Linux, the recommended installation method is:
curl -fsSL https://ntn.dev | bash
You can also install it through npm:
npm install --global ntn
The npm installation currently requires Node.js 22 or newer.
On Windows, you can use Winget:
winget install Notion.ntn
Verify the installation:
ntn --version
You can find all the installation options in the Notion CLI installation guide.
Log in to a workspace
Run:
ntn login
The CLI opens a browser and asks you to authorize a workspace. Check that the code shown in the browser matches the code in the terminal before approving it.
The token is stored in the operating system credential store. On macOS, this is Keychain.
You need full workspace membership to log in with the CLI. Guests and restricted members cannot use this flow.
If you are working on a remote server, use:
ntn login --no-browser
The CLI prints a URL and a verification code. Open the URL on another device, approve the login, then run the polling command printed in the terminal.
The authentication guide also explains personal access tokens for CI and unattended scripts.
Start from the official template
Clone the template:
git clone https://github.com/makenotion/notion-as-code-template my-notion-workspace
cd my-notion-workspace
npm install
The important files are:
src/main.ts
src/content.ts
src/lib/notion.ts
src/lib/types.d.ts
AGENTS.md
src/main.ts is the entry point.
src/lib/ contains the vendored runtime and TypeScript types. Do not edit those files. They show your editor, TypeScript, and coding agents what the beta supports.
AGENTS.md explains how an agent should work inside the project. This is useful because Notion as Code is designed to work well with coding agents, but an agent still needs rules.
Define a teamspace
Let’s create an Engineering teamspace.
First we need a stable workspace anchor:
import { notion } from './lib/notion'
const workspace = {
type: 'resourceId',
resourceId: 'acme-workspace',
} as const
The CLI connects this anchor to the workspace selected during login.
Now we can add the teamspace:
const engineering = notion.teamspace({
resourceId: 'engineering-teamspace',
parent: workspace,
name: 'Engineering',
accessLevel: 'open',
icon: {
type: 'notion_icon',
description: 'code',
color: 'blue',
},
description: 'Projects, decisions, and technical documentation.',
})
Notice the two names:
engineering-teamspaceis the stable resource ID used by the codeEngineeringis the name people see in Notion
You can rename the teamspace in the name property later. Keep the resourceId unchanged.
This is the most important rule in Notion as Code.
A
resourceIdis identity, not presentation.
Treat it like a database primary key. Use lowercase names that describe the purpose of a resource. Do not include a title that might change, a date, or a team member’s name unless that value is truly part of its identity.
Add a page with Markdown content
We can add a page through the teamspace handle:
engineering.addPage({
resourceId: 'engineering-home',
properties: {
title: notion.text('Engineering home'),
},
icon: {
type: 'emoji',
emoji: '🛠️',
},
content: `# Welcome
This is the home of the engineering team.
## Start here
- Read the technical principles
- Find the current projects
- Record an architectural decision
`,
})
The title lives in properties.title. The Markdown is page content, not the title.
Notion uses an extended form of Markdown that can represent more than headings and lists. It also supports Notion blocks and rich text features.
For longer pages, keep the content in another file:
export const engineeringHome = `# Welcome
This is the home of the engineering team.
`
Then import it into main.ts:
import { engineeringHome } from './content'
This keeps the workspace structure readable. It also gives Git a clean diff when someone changes the page content.
Create a database schema
Now let’s create a Projects database.
This example has a title, status, priority, and target date:
const projectsDatabase = engineering.addDatabase({
resourceId: 'projects-database',
name: 'Projects',
icon: {
type: 'notion_icon',
description: 'target',
color: 'purple',
},
description: 'The projects owned by the engineering team.',
dataSources: [
{
resourceId: 'projects-data-source',
name: 'Projects',
properties: [
{
resourceId: 'project-name-property',
name: 'Name',
type: 'title',
},
{
resourceId: 'project-status-property',
name: 'Status',
type: 'status',
options: {
todo: [
{ name: 'Not started', color: 'gray', default: true },
],
inProgress: [
{ name: 'In progress', color: 'blue' },
],
complete: [
{ name: 'Done', color: 'green' },
],
},
},
{
resourceId: 'project-priority-property',
name: 'Priority',
type: 'select',
options: [
{ name: 'High', color: 'red' },
{ name: 'Medium', color: 'yellow' },
{ name: 'Low', color: 'green' },
],
},
{
resourceId: 'project-target-property',
name: 'Target',
type: 'date',
},
],
},
],
})
Properties have stable resource IDs too.
This matters when another part of the definition refers to a property. A board view groups by project-status-property, not by the visible label Status.
The visible label can change without breaking the reference.
Seed database entries
Get the data source handle:
const projects = projectsDatabase.getDataSource(
'projects-data-source'
)
Then add a page:
projects.addPage({
resourceId: 'project-new-checkout',
properties: {
Name: notion.text('Build the new checkout'),
Status: notion.status('In progress'),
Priority: notion.select('High'),
Target: notion.date('2026-08-15'),
},
})
The TypeScript types know the database schema. Your editor can catch a misspelled property name or the wrong value type before anything reaches Notion.
You do not have to write all the data inline.
The build runs under Node.js, so you can import JSON, read a CSV file, read environment variables, or install an npm package. The official template includes a JSON file and loops over its rows.
Example:
import projectRows from './data/projects.json' with { type: 'json' }
for (const project of projectRows) {
projects.addPage({
resourceId: project.id,
properties: {
Name: notion.text(project.name),
Status: notion.status(project.status),
Priority: notion.select(project.priority),
Target: notion.date(project.target),
},
})
}
Only the recorded intents are sent to Notion. The local file-reading code stays local.
This makes Notion as Code useful for migrations and repeatable setup. You can turn a CSV, a JSON configuration file, or another internal source into a complete workspace.
Add database views
A database becomes much more useful when the correct views already exist.
Let’s add a table and a board:
projectsDatabase.addView({
resourceId: 'projects-table-view',
name: 'All projects',
type: 'table',
dataSourceResourceId: 'projects-data-source',
properties: [
{ property: 'project-name-property', visible: true },
{ property: 'project-status-property', visible: true },
{ property: 'project-priority-property', visible: true },
{ property: 'project-target-property', visible: true },
],
})
projectsDatabase.addView({
resourceId: 'projects-board-view',
name: 'Projects by status',
type: 'board',
dataSourceResourceId: 'projects-data-source',
groupBy: {
property: 'project-status-property',
type: 'status',
emptyGroupVisibility: 'hide',
},
properties: [
{ property: 'project-priority-property', visible: true },
{ property: 'project-target-property', visible: true },
],
})
The beta supports table, board, calendar, list, gallery, and timeline views.
Views can also carry sorts and filters. This is where workspace reuse gets interesting.
Imagine a central Docs database shared across the company. Each teamspace can start with a view filtered to its own team. The schema stays consistent, but people see the slice that matters to them.
This is exactly the kind of detail teams usually rebuild by hand.
Define a custom agent
Custom agents are part of the workspace definition too.
We can create an agent that reviews project pages:
notion.customAgent({
resourceId: 'project-reviewer-agent',
name: 'Project reviewer',
icon: {
type: 'emoji',
emoji: '🔎',
},
instructions: `# Project reviewer
Review project pages for missing context.
Check that each project has:
- a clear outcome
- an owner
- a target date
- a definition of done
Ask questions when information is missing.
`,
sharedResources: [projectsDatabase.resourceId],
})
The agent receives access to the resources listed in sharedResources.
Its instructions are Markdown. Notion turns them into a hidden instruction page used by the agent runtime.
The beta type definitions also expose a model property, but the available values are internal codenames that can change. The template itself warns that they need a stable public naming scheme.
I would omit model for now. Let the agent inherit the workspace default until this part of the API becomes stable.
Type-check before applying
Run:
npm run typecheck
This catches TypeScript errors without changing Notion.
Then build the project:
npm run build
The build writes the recorded intents to dist/intents.json.
Open that file and inspect it. It is the closest thing the current template gives us to a preview of the deployment.
This is a useful safety boundary:
- edit the TypeScript
- type-check it
- build the intents
- review the diff and generated intents
- apply to Notion
Keep the first four steps automatic. Keep the fifth deliberate.
Apply the workspace
Apply the project with:
ntn notion-as-code apply .
The CLI builds the project, submits the intents, and saves local state.
Now change a page title or add a database property and run the command again.
The same resourceId maps to the same Notion resource, so the CLI updates it instead of creating a duplicate.
For machine-readable output, use:
ntn notion-as-code apply . --json
Understand state names
The default deployment state is named default.
You can give it another name:
ntn notion-as-code apply . --name staging
State is stored under the Notion CLI configuration directory. It is separated by environment, workspace ID, and state name.
List saved states:
ntn notion-as-code list
Inspect one:
ntn notion-as-code state show staging
Remove one:
ntn notion-as-code state rm staging
Be careful with the last command.
Removing local state does not turn the existing Notion resources back into unnamed resources the CLI can magically rediscover. The next apply has no mapping and can create a fresh set.
State is not a cache you can delete whenever something feels stuck. It is part of the identity system.
Put the definition in Git
Once the first apply works, commit the project.
git add src package.json package-lock.json
git commit -m "Define the engineering workspace"
Now a workspace change can use the same flow as a code change:
- create a branch
- change the TypeScript
- run the type checker
- review the diff
- merge it
- apply it
The diff answers questions the Notion UI usually cannot answer clearly.
Who added this property? Why does this teamspace exist? When did the agent instructions change? Was the Finance view meant to include archived projects?
Git gives the workspace an inspectable history.
But a Git revert is not a complete Notion rollback.
Reverting the TypeScript and applying again can restore fields managed by the definition. It cannot promise to restore every manual edit, deleted page, comment, or piece of content that was never in code.
Notion as Code does not replace workspace backups.
Use it with a coding agent
This feature is a natural fit for coding agents because the interface is TypeScript.
An agent can inspect the existing definition, read the vendored types, make a focused change, and run the type checker. It can show you the code diff before anything changes in Notion.
The launch demo uses this pattern. The prompt asks for several teamspaces, central Docs and Meetings databases, filtered team views, and two custom agents. It also asks the agent to provide a TypeScript outline for review before deployment.
That last sentence is the important one.
Do not give an agent a vague prompt and let it apply immediately. Separate authoring from deployment.
I would put rules like these in AGENTS.md:
- Read src/lib/types.d.ts before using a new resource type.
- Keep every resourceId stable and unique.
- Run npm run typecheck after each change.
- Run npm run build and inspect dist/intents.json.
- Never run ntn notion-as-code apply without explicit approval.
The agent is good at generating repetitive schema code. You remain responsible for the workspace architecture and the final apply.
Use it in CI carefully
The CLI supports a personal access token through the NOTION_API_TOKEN environment variable.
This makes an automated deployment possible. Store the token in your CI secret manager and run the apply after a merge.
But I would not start there.
First learn what an apply does in a test workspace. Then add a protected deployment job that requires approval.
A safe pipeline can look like this:
Pull request:
type-check -> build -> review intents
After merge:
manual approval -> apply
Never print the token. Never put it in .env and commit the file. Never let pull requests from untrusted forks run with the workspace token.
The TypeScript project can read local files and environment variables. That power is useful, but it also means you must treat changes to the project like changes to deployment code.
Decide what code owns
The hardest problem is not TypeScript. It is ownership.
Suppose the code creates a page called “Engineering principles”. A team member later improves the page in Notion. Then someone changes the content string in Git and applies it.
Which version should win?
You need a rule before this happens.
I would divide resources into three groups.
Code-owned structure
This includes teamspaces, database schemas, standard views, page templates, agent instructions, and navigation hubs.
Change these in Git. Review them. Apply them.
Human-owned content
This includes project updates, meeting notes, comments, research, and documents that change every day.
Create a home for them in code, then let people work in Notion.
Imported data
This includes data generated from JSON, CSV, or another system.
Choose one source of truth. If another system owns the data, make the Notion version clearly derived. Do not invite people to edit both copies and expect the beta to resolve the conflict.
This boundary keeps the repository calm and the workspace useful.
Plan for drift
People will still make manual changes in Notion.
Some changes will be harmless. Someone may add a meeting note or update a project status.
Other changes will conflict with the definition. Someone may rename a property, delete a view, or change an agent’s instructions directly in Notion.
The current beta should not be treated as a mature Terraform replacement. The public template documents create and update behavior, but it does not present a full plan, import, drift detection, and destroy workflow.
Do not assume that removing a resource from TypeScript will safely delete it from Notion. Do not assume an apply can reconstruct every manual change.
Test these cases in a disposable workspace before setting team policy around them.
The simplest policy is also the clearest: if code owns a resource, edit it through code.
Good uses for Notion as Code
I can see several strong use cases.
An agency can keep a client workspace template in Git. Each new client gets the same project database, meeting system, documentation hub, and onboarding agent.
A company can reproduce its structure for regional teams. Each workspace starts with the same schema and permissions, while local teams own their content.
A course creator can create a fresh workspace for every cohort. Pages, databases, views, and support agents start in a known state.
A product team can build disposable demo workspaces. Every salesperson gets the same realistic sample data instead of a workspace copied by hand six months ago.
A developer can version a personal operating system. The value is not that the system is complicated. The value is that it can be rebuilt.
The custom-agent part makes this even more interesting.
We are no longer reproducing just folders and databases. We can reproduce the people-like software inside the workspace: their instructions, the resources they can access, and the job they are meant to do.
That turns a workspace template into an operating model.
The deeper idea
Notion as Code does not make all of Notion code.
That would miss the point. Notion is useful because people can open it and work together without deploying anything.
What becomes code is the design of the environment where that work happens.
This gives us a useful split:
- people create knowledge in Notion
- code creates the structure around that knowledge
- Git records why the structure changed
- agents help maintain both sides
For years, teams have treated workspace design as a one-time setup task. Someone builds a complicated Notion system, records a walkthrough video, and hopes nobody breaks it.
Now the system can have a source file.
It can be reviewed, tested, copied, and improved. A coding agent can help change it. A new workspace can start from the current version instead of an old duplicate.
That is the real promise of Notion as Code.
The TypeScript syntax is the easy part. The interesting part is that a workspace can become a maintained product instead of a pile of settings.
Related posts about tools: