---
title: "Page Block — Links to Sub-Pages"
description: "A one-line link to a sub-page that your app stores as its own document. Host hooks open, create and refresh each page."
source: https://blokeditor.com/docs/page/
lastmod: 2026-10-04
---

Framework JavaScript

Block Tools Page

# Page block: links to sub-pages

A one-line link to a sub-page, like a sub-page in Notion. It shows the page's icon and title. Each page is its own document, and your app stores it. The block saves only the page id and a display copy of the title and icon.

Blok never loads or saves a page. Your app connects pages through the four config members below.

The block has three special states:

- New page: the page has no title, so the block shows New page instead. It is still a link.
- Page not found: `resolve` returned `null`.
- No access: `resolve` returned `{ access: 'none' }`. The cached title is hidden.

A page that is not found or not accessible is never removed automatically. The block stays, but it stops working as a link.

A plain click calls `open`. So does Enter when the block is selected from the keyboard. Without `open`, the link follows `href`.

Cmd/Ctrl-click, Shift-click and middle click keep their browser meaning, such as opening a new tab. New-tab clicks need `href`. The link never takes focus, so undo and Escape still work after a click.

`page` is not in `defaultBlockTools`. Register it yourself, with a config.

For read-only HTML, call `blocksToHtml(data, { pageHref })` from `@bloklabs/core/view`. It renders the block as an icon and a title, and it is a link only when you pass `pageHref`. The view reads only the saved cache, so it cannot show the not-found or no-access states. It never renders the page's body.

### Import

TypeScript

```
import { Page } from '@bloklabs/core/tools';
```

### Configuration

| Option | Type | Default | Description |
| --- | --- | --- | --- |
| `href` | `(pageId: string) => string` | `undefined` | Builds the page's URL from its id. Blok puts it on the link, so Cmd/Ctrl-click or middle click opens the page in a new tab. Without it the link has no URL. Unsafe schemes such as `javascript:` are dropped. |
| `open` | `(pageId: string, ctx: { event?: MouseEvent | KeyboardEvent }) => void` | `undefined` | Opens the page. Blok calls it on a plain left click. Without it, a click follows `href`. Enter also opens the page when the block is selected from the keyboard (Escape, then the arrow keys). Then `ctx.event` is that key press. Blok also calls it once right after a user inserts a new page from the toolbox. Then `ctx.event` is empty. It is not called for a page inserted through the API, or when `create` throws. |
| `resolve` | `(pageId: string) => PageInfo | null | undefined | Promise<…>` | `undefined` | Returns the page's current title and icon. Blok asks once, when the block renders. It may return a value or a promise: `{ title, icon }`: the cached copy is updated if it changed. `null`: the page does not exist. The block shows "Page not found". `{ access: 'none' }`: this user may not see the page. The block shows "No access". `undefined`: nothing is known. The cached copy stays. Return the full picture. A missing `title` or `icon` means the page has none. In read-only mode the fresh copy is shown but not saved. It is saved when editing turns on. If `resolve` throws, the cached copy stays. |
| `create` | `(init: { pageId: string }) => void | Promise<void>` | `undefined` | Makes the page in your app. Blok calls it once with the id it minted, when a page block is inserted without a `pageId`. That happens when a user picks Page in the toolbox, or when your code inserts one through the API. It is never called on load, paste, undo, redo or a collaborator's change, or in read-only mode. If it throws, the block stays with its id and shows "Page not found". Blok still asks `resolve`, so a page your app made after all shows up. |

### Save Data

TypeScript

```
interface PageData {
  pageId: string; // Id of the page. Blok mints one for a new page.
  cache?: {       // Display copy, refreshed from resolve(). It can be stale.
    title?: string;
    icon?: { type: 'emoji'; value: string } | { type: 'image'; url: string };
  };
}
// The page's body is NOT here. It is a separate document your app stores.
```

JSON

```
{
  "id": "pg001",
  "type": "page",
  "data": {
    "pageId": "p1",
    "cache": {
      "title": "Roadmap",
      "icon": { "type": "emoji", "value": "🗺" }
    }
  }
}
```

### Usage Example

TypeScript

```
import { Blok } from '@bloklabs/core';
import { Page } from '@bloklabs/core/tools';
import { blocksToHtml } from '@bloklabs/core/view';

const pageUrl = (pageId) => `/pages/${pageId}`;

const editor = new Blok({
  holder: 'editor',
  tools: {
    // Not in defaultBlockTools: register it with your own config.
    page: {
      class: Page,
      config: {
        href: pageUrl,
        open: (pageId) => router.push(pageUrl(pageId)),
        resolve: (pageId) => myApi.getPageInfo(pageId), // { title, icon } | null
        create: ({ pageId }) => myApi.createPage(pageId),
      },
    },
  },
});

// Read-only HTML: pass the same URL builder.
const html = blocksToHtml(savedData, { pageHref: pageUrl });
```
