# How to use Notion as Code

> Learn how Notion as Code defines teamspaces, pages, databases, views, entries, and custom agents in TypeScript, then deploys them with the Notion CLI.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-07-24 | Topics: [Tools](https://flaviocopes.com/tags/tools/) | Canonical: https://flaviocopes.com/how-to-use-notion-as-code/

Notion just introduced [Notion as Code](https://x.com/NotionHQ/status/2080331924732850687), 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](https://github.com/makenotion/notion-as-code-template) still calls the underlying system alpha and experimental.

Notion also published a [How to use Notion as Code](https://app.notion.com/p/notionambassadors/How-to-use-Notion-as-Code-3973139dbfef802eb77cfbe7cf08c12a) 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:

```text
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:

```bash
curl -fsSL https://ntn.dev | bash
```

You can also install it through npm:

```bash
npm install --global ntn
```

The npm installation currently requires Node.js 22 or newer.

On Windows, you can use Winget:

```bash
winget install Notion.ntn
```

Verify the installation:

```bash
ntn --version
```

You can find all the installation options in the [Notion CLI installation guide](https://developers.notion.com/cli/get-started/installation).

## Log in to a workspace

Run:

```bash
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:

```bash
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](https://developers.notion.com/cli/get-started/authentication) also explains personal access tokens for CI and unattended scripts.

## Start from the official template

Clone the template:

```bash
git clone https://github.com/makenotion/notion-as-code-template my-notion-workspace
cd my-notion-workspace
npm install
```

The important files are:

```text
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:

```ts
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:

```ts
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-teamspace` is the stable resource ID used by the code
- `Engineering` is 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 `resourceId` is 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:

```ts
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:

```ts
export const engineeringHome = `# Welcome

This is the home of the engineering team.
`
```

Then import it into `main.ts`:

```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:

```ts
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:

```ts
const projects = projectsDatabase.getDataSource(
  'projects-data-source'
)
```

Then add a page:

```ts
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:

```ts
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:

```ts
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:

```ts
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:

```bash
npm run typecheck
```

This catches TypeScript errors without changing Notion.

Then build the project:

```bash
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:

1. edit the TypeScript
2. type-check it
3. build the intents
4. review the diff and generated intents
5. apply to Notion

Keep the first four steps automatic. Keep the fifth deliberate.

## Apply the workspace

Apply the project with:

```bash
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:

```bash
ntn notion-as-code apply . --json
```

## Understand state names

The default deployment state is named `default`.

You can give it another name:

```bash
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:

```bash
ntn notion-as-code list
```

Inspect one:

```bash
ntn notion-as-code state show staging
```

Remove one:

```bash
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.

```bash
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`:

```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:

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