Aphex
Schema Types

Block Content

Rich text editing with Portable Text — an array of blocks with custom types, inline objects, and annotations.

Block content in Aphex uses Portable Text, a structured JSON format for rich text. Rich text is modeled as an array of blocks — not a standalone field type. This follows the same convention as Sanity.

When an array's of contains {type: 'block'}, Aphex activates the Portable Text editor instead of the regular array UI.

Minimal Configuration

{
  name: 'content',
  type: 'array',
  title: 'Content',
  of: [{ type: 'block' }]
}

This renders a full rich text editor with defaults:

  • Styles: Normal, H1–H6, Blockquote
  • Decorators: Bold, Italic, Underline, Strikethrough, Code
  • Lists: Bullet, Numbered
  • Links: Built-in link annotation

Customizing the Block Type

You can configure styles, lists, decorators, and annotations on the block entry:

{
  name: 'content',
  type: 'array',
  title: 'Content',
  of: [
    {
      type: 'block',
      styles: [
        { title: 'Normal', value: 'normal' },
        { title: 'H1', value: 'h1' },
        { title: 'H2', value: 'h2' },
        { title: 'Quote', value: 'blockquote' }
      ],
      lists: [
        { title: 'Bullet', value: 'bullet' }
      ],
      marks: {
        decorators: [
          { title: 'Bold', value: 'strong' },
          { title: 'Italic', value: 'em' }
        ],
        annotations: [
          {
            name: 'internalLink',
            title: 'Internal Link',
            fields: [
              { name: 'reference', type: 'reference', title: 'Document', to: [{ type: 'page' }] }
            ]
          }
        ]
      }
    }
  ]
}

Block Properties

PropertyTypeRequiredDescription
type'block'YesMust be 'block'.
stylesArray<{ title: string; value: string }>NoAvailable block styles. Defaults to Normal, H1–H3, Blockquote. Set to [] to disable all styles except Normal.
listsArray<{ title: string; value: string }>NoAvailable list types. Defaults to Bullet and Number. Set to [] to disable lists.
marks.decoratorsArray<{ title: string; value: string }>NoInline text decorators. Defaults to Bold, Italic, Underline, Strikethrough, Code.
marks.annotationsAnnotationDefinition[]NoCustom annotations that attach structured data to inline text (see below).
ofArray<{ type: string; title?: string; fields?: Field[] }>NoInline object types that appear within the text flow (see below).

The link button is always present in the editor toolbar regardless of what you put in marks.annotations. It uses TipTap's link extension and serializes as a markDef with _type: 'link' and an href field — identical to how you'd define it as an annotation yourself. You can add link to marks.annotations if you need extra fields (e.g. a blank toggle) and the editor will use your annotation definition instead.

On the frontend, render it via components.marks.link:

<!-- MarkLink.svelte -->
<a href={portableText.value.href} target={portableText.value.blank ? '_blank' : undefined}>
  {@render children()}
</a>

Default Values

If you omit a property, these defaults apply:

PropertyDefault
stylesnormal, h1, h2, h3, h4, h5, h6, blockquote
listsbullet, number
marks.decoratorsstrong, em, underline, strike-through, code
marks.annotationsNone
ofNone

Annotations

Annotations let you attach structured data to selected text. Common uses: internal links, footnotes, citations.

marks: {
  annotations: [
    {
      name: 'footnote',
      title: 'Footnote',
      fields: [
        { name: 'text', type: 'text', title: 'Footnote text' }
      ]
    }
  ]
}

In the editor, annotations appear as toolbar buttons. Selecting text and clicking an annotation opens a modal to fill in the fields. The data is stored in the block's markDefs array per the Portable Text spec.

Stored data

{
  "_type": "block",
  "children": [
    { "_type": "span", "text": "See this note", "marks": ["fn1"] }
  ],
  "markDefs": [
    { "_type": "footnote", "_key": "fn1", "text": "Important detail" }
  ]
}

Inline Objects

Inline objects are custom content that appears within a line of text, alongside regular text spans. Use these for footnote markers, mentions, ticker symbols, etc.

Define them in the block's of property:

{
  type: 'block',
  of: [
    {
      type: 'inlineNote',
      title: 'Inline Note',
      fields: [
        { name: 'text', type: 'text', title: 'Note text' }
      ]
    }
  ]
}

Inline objects render as small chips in the editor. Click to edit, or use the + insert menu.

Stored data

Inline objects are stored as children of the block, alongside spans:

{
  "_type": "block",
  "children": [
    { "_type": "span", "text": "See " },
    { "_type": "inlineNote", "_key": "n1", "text": "A note" },
    { "_type": "span", "text": " here." }
  ]
}

Custom Block Types

Custom block types are siblings of {type: 'block'} in the array's of. They appear between paragraphs as separate content blocks (images, code blocks, embeds, etc.).

{
  name: 'content',
  type: 'array',
  title: 'Content',
  of: [
    { type: 'block' },
    { type: 'image', title: 'Image' },
    {
      type: 'callout',
      title: 'Callout',
      fields: [
        { name: 'tone', type: 'string', title: 'Tone' },
        { name: 'text', type: 'text', title: 'Text' }
      ]
    },
    {
      type: 'codeBlock',
      title: 'Code Block',
      fields: [
        { name: 'language', type: 'string', title: 'Language' },
        { name: 'code', type: 'text', title: 'Code' }
      ]
    }
  ]
}

Custom types appear in the + insert menu in the toolbar. Clicking one opens a modal to fill in the fields. You can define them inline with fields or reference a registered object schema by name.

Built-in Image Block

{type: 'image'} is a built-in block type with special handling:

  • Inserting opens the asset browser (not a modal form)
  • Clicking an existing image block opens the asset browser to replace it
  • The image renders as a preview in the editor
of: [
  { type: 'block' },
  { type: 'image', title: 'Image' }
]

Stored data

Custom blocks are stored as objects in the root array alongside text blocks:

[
  {
    "_type": "block",
    "_key": "b1",
    "style": "normal",
    "children": [{ "_type": "span", "text": "Here is a photo:" }]
  },
  {
    "_type": "image",
    "_key": "img1",
    "asset": { "_type": "reference", "_ref": "asset-id-here" }
  }
]

Full Example

{
  name: 'body',
  type: 'array',
  title: 'Body',
  of: [
    {
      type: 'block',
      styles: [
        { title: 'Normal', value: 'normal' },
        { title: 'H2', value: 'h2' },
        { title: 'H3', value: 'h3' },
        { title: 'Quote', value: 'blockquote' }
      ],
      lists: [
        { title: 'Bullet', value: 'bullet' },
        { title: 'Numbered', value: 'number' }
      ],
      marks: {
        decorators: [
          { title: 'Bold', value: 'strong' },
          { title: 'Italic', value: 'em' },
          { title: 'Code', value: 'code' }
        ],
        annotations: [
          {
            name: 'internalLink',
            title: 'Internal Link',
            fields: [
              { name: 'reference', type: 'reference', title: 'Page', to: [{ type: 'page' }] }
            ]
          },
          {
            name: 'footnote',
            title: 'Footnote',
            fields: [
              { name: 'text', type: 'text', title: 'Footnote text' }
            ]
          }
        ]
      },
      of: [
        { type: 'inlineNote', title: 'Inline Note', fields: [{ name: 'text', type: 'text', title: 'Note' }] }
      ]
    },
    { type: 'image', title: 'Image' },
    {
      type: 'callout',
      title: 'Callout',
      fields: [
        { name: 'tone', type: 'string', title: 'Tone' },
        { name: 'text', type: 'text', title: 'Text' }
      ]
    }
  ],
  validation: (Rule) => Rule.required()
}

Editor Behaviour

Hard break

Press Shift+Enter to insert a hard break (\n) within the current paragraph instead of starting a new block. Serialized as a \n character inside a span.

Expand mode

The toolbar has an expand button (⤢) that stretches the editor to fill the document panel, giving more room for long-form content. Press Escape or click the button again to collapse it back.

Keyboard shortcuts

ActionmacOSWindows/Linux
Bold⌘BCtrl+B
Italic⌘ICtrl+I
Underline⌘UCtrl+U
Link⌘KCtrl+K
Undo⌘ZCtrl+Z
Redo⌘⇧ZCtrl+Y

Validation

Since block content is a regular array, standard array validation rules apply:

// Required — at least one block
validation: (Rule) => Rule.required()

// Min/max block count
validation: (Rule) => Rule.min(1).max(50)

// Custom validation — e.g. minimum text length
validation: (Rule) => Rule.custom((blocks) => {
  if (!Array.isArray(blocks)) return true;
  const text = blocks
    .filter((b) => b._type === 'block')
    .flatMap((b) => b.children?.map((c) => c.text) ?? [])
    .join('');
  return text.length >= 10 || 'Content must be at least 10 characters';
})

Rendering Portable Text

Block content is stored as Portable Text JSON. The CMS stores the data — how it renders is entirely up to your frontend. Use a Portable Text serializer for your framework:

Install the Svelte package:

npm i @portabletext/svelte -D

The components prop

@portabletext/svelte renders Portable Text with zero styling by default. You control how every element looks by passing a components object:

const components = {
  // Custom block-level types (image, callout, code, etc.)
  types: { ... },

  // Block style renderers (normal, h1, h2, blockquote, etc.)
  block: { ... },

  // Inline mark renderers (bold, code, link, annotations, etc.)
  marks: { ... },

  // List wrapper renderers
  list: { ... },

  // List item renderers
  listItem: { ... }
};

Block styles

Use components.block to control how each block style renders. Each component receives portableText (with the block's metadata) and children (the block's inline content):

<!-- BlockHeading.svelte -->
<script lang="ts">
  import type { BlockComponentProps } from '@portabletext/svelte';
  import type { Snippet } from 'svelte';

  interface Props {
    portableText: BlockComponentProps;
    children: Snippet;
  }

  let { portableText, children }: Props = $props();
  let { style } = $derived(portableText.value);
</script>

{#if style === 'h1'}
  <h1 class="mt-6 mb-2 text-4xl font-bold">{@render children()}</h1>
{:else if style === 'h2'}
  <h2 class="mt-5 mb-2 text-3xl font-semibold">{@render children()}</h2>
{:else if style === 'h3'}
  <h3 class="mt-4 mb-2 text-2xl font-semibold">{@render children()}</h3>
{:else if style === 'h4'}
  <h4 class="mt-4 mb-1 text-xl font-semibold">{@render children()}</h4>
{:else if style === 'h5'}
  <h5 class="mt-3 mb-1 text-lg font-semibold">{@render children()}</h5>
{:else if style === 'h6'}
  <h6 class="mt-3 mb-1 text-sm font-semibold uppercase tracking-wide text-gray-500">
    {@render children()}
  </h6>
{/if}
<!-- BlockQuote.svelte -->
<script lang="ts">
  import type { BlockComponentProps } from '@portabletext/svelte';
  import type { Snippet } from 'svelte';

  interface Props {
    portableText: BlockComponentProps;
    children: Snippet;
  }

  let { children }: Props = $props();
</script>

<blockquote class="my-3 border-l-3 border-gray-300 pl-4 italic text-gray-500">
  {@render children()}
</blockquote>

Marks (decorators & annotations)

Use components.marks to control how inline marks render. Decorators like strong and em use browser defaults, but you can override them. Custom annotations receive the annotation's data via portableText.value:

<!-- MarkCode.svelte -->
<script lang="ts">
  import type { MarkComponentProps } from '@portabletext/svelte';
  import type { Snippet } from 'svelte';

  interface Props {
    portableText: MarkComponentProps;
    children: Snippet;
  }

  let { children }: Props = $props();
</script>

<code class="rounded bg-gray-100 px-1.5 py-0.5 font-mono text-sm">
  {@render children()}
</code>
<!-- MarkLink.svelte -->
<script lang="ts">
  import type { MarkComponentProps } from '@portabletext/svelte';
  import type { Snippet } from 'svelte';

  interface Props {
    portableText: MarkComponentProps<{ href?: string }>;
    children: Snippet;
  }

  let { portableText, children }: Props = $props();
</script>

<a
  href={portableText.value.href}
  class="text-blue-600 underline hover:text-blue-800"
  target={portableText.value.href?.startsWith('http') ? '_blank' : undefined}
>
  {@render children()}
</a>

For custom annotations (like an internal link), the data from the annotation's fields is available on portableText.value:

<!-- InternalLink.svelte -->
<script lang="ts">
  import type { MarkComponentProps } from '@portabletext/svelte';
  import type { Snippet } from 'svelte';

  interface Props {
    portableText: MarkComponentProps<{ reference?: { _ref?: string } }>;
    children: Snippet;
  }

  let { portableText, children }: Props = $props();
  const ref = $derived(portableText.value.reference?._ref);
</script>

<a href="/posts/{ref}" class="text-blue-600 underline">
  {@render children()}
</a>

Custom block types

Use components.types for block-level custom types (image, callout, code, etc.). Each component receives the full block data via portableText.value:

<!-- ImageBlock.svelte -->
<script lang="ts">
  import type { CustomBlockComponentProps } from '@portabletext/svelte';

  interface Props {
    portableText: CustomBlockComponentProps<{
      asset?: { _ref?: string };
      alt?: string;
      caption?: string;
    }>;
  }

  let { portableText }: Props = $props();
  const assetRef = portableText.value.asset?._ref;
</script>

{#if assetRef}
  <figure class="my-4">
    <img src="/api/assets/{assetRef}/file" alt={portableText.value.alt || ''} class="rounded" />
    {#if portableText.value.caption}
      <figcaption class="mt-1 text-sm text-gray-500">{portableText.value.caption}</figcaption>
    {/if}
  </figure>
{/if}
<!-- Callout.svelte -->
<script lang="ts">
  import type { CustomBlockComponentProps } from '@portabletext/svelte';

  interface Props {
    portableText: CustomBlockComponentProps<{ tone?: string; text?: string }>;
  }

  let { portableText }: Props = $props();
</script>

<div class="my-4 rounded-lg border-l-4 p-4
  {portableText.value.tone === 'warning' ? 'border-yellow-500 bg-yellow-50' :
   portableText.value.tone === 'error' ? 'border-red-500 bg-red-50' :
   'border-blue-500 bg-blue-50'}">
  {portableText.value.text}
</div>

Putting it all together

<script>
  import { PortableText } from '@portabletext/svelte';
  import BlockHeading from './BlockHeading.svelte';
  import BlockQuote from './BlockQuote.svelte';
  import MarkCode from './MarkCode.svelte';
  import MarkLink from './MarkLink.svelte';
  import InternalLink from './InternalLink.svelte';
  import ImageBlock from './ImageBlock.svelte';
  import Callout from './Callout.svelte';

  let { data } = $props();

  const components = {
    types: {
      image: ImageBlock,
      callout: Callout
    },
    block: {
      h1: BlockHeading,
      h2: BlockHeading,
      h3: BlockHeading,
      h4: BlockHeading,
      h5: BlockHeading,
      h6: BlockHeading,
      blockquote: BlockQuote
    },
    marks: {
      code: MarkCode,
      link: MarkLink,
      internalLink: InternalLink
    }
  };
</script>

<PortableText value={data.body} {components} />

Styles not listed in components.block (like normal) use the default <p> tag. Marks not listed in components.marks (like strong, em) use their default HTML elements.

For full API details, see the @portabletext/svelte documentation.

Edit on GitHub

Last updated on