Skip to content

Instantly share code, notes, and snippets.

@kmelve
Created March 17, 2026 20:06
Show Gist options
  • Select an option

  • Save kmelve/1e383d2cde8b7d4048ffcc1940cfd87f to your computer and use it in GitHub Desktop.

Select an option

Save kmelve/1e383d2cde8b7d4048ffcc1940cfd87f to your computer and use it in GitHub Desktop.
Footnotes in Sanity Portable Text + Next.js — minimal reproducible implementation
// Schema: main body Portable Text with footnote annotation
// This shows how to register footnote as a mark annotation
import { BlockquoteIcon, LaunchIcon } from "@sanity/icons";
import { defineArrayMember, defineField, defineType } from "sanity";
export const bodyPortableText = defineType({
name: "bodyPortableText",
type: "array",
title: "Content",
of: [
defineArrayMember({
type: "block",
marks: {
annotations: [
{
name: "link",
type: "object",
title: "External link",
icon: LaunchIcon,
fields: [
defineField({
name: "href",
type: "url",
title: "URL",
validation: (rule) =>
rule.uri({
scheme: ["http", "https", "mailto", "tel"],
}),
}),
],
},
{
name: "footnote",
type: "object",
title: "Footnote",
icon: BlockquoteIcon,
fields: [
defineField({
name: "text",
type: "excerptPortableText",
title: "Footnote text",
}),
],
},
],
},
}),
// Add your other block types here (images, code, embeds, etc.)
],
});
// Schema: simple Portable Text type used for footnote text content
// Supports basic formatting (bold, italic, code) but no images/embeds
import { defineType } from "sanity";
export const excerptPortableText = defineType({
name: "excerptPortableText",
type: "array",
title: "Excerpt",
of: [
{
title: "Block",
type: "block",
styles: [{ title: "Normal", value: "normal" }],
lists: [],
marks: {
decorators: [
{ title: "Strong", value: "strong" },
{ title: "Emphasis", value: "em" },
{ title: "Code", value: "code" },
],
annotations: [],
},
},
],
});
// Schema: footnote annotation object type
// Register this in your Sanity schema configuration
import { BlockquoteIcon } from "@sanity/icons";
import { defineField, defineType } from "sanity";
export const footnote = defineType({
name: "footnote",
type: "object",
title: "Footnote",
icon: BlockquoteIcon,
fields: [
defineField({
name: "text",
type: "excerptPortableText",
}),
],
});
// Component: renders the footnotes section at the bottom of an article
import { PortableText, PortableTextBlock } from "next-sanity";
import { Footnote } from "./footnotes"; // adjust import path
/**
* Parse markdown-style links in a string: [text](url)
*/
function parseMarkdownLinks(text: string): React.ReactNode[] {
const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
const parts: React.ReactNode[] = [];
let lastIndex = 0;
let match;
while ((match = linkRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
const isExternal = match[2].startsWith("http");
parts.push(
<a
key={match.index}
href={match[2]}
className="underline"
{...(isExternal && { target: "_blank", rel: "noopener noreferrer" })}
>
{match[1]}
</a>
);
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts.length > 0 ? parts : [text];
}
/**
* Footnotes section — renders at the bottom of the article
*/
export function Footnotes({ footnotes }: { footnotes: Footnote[] }) {
if (footnotes.length === 0) return null;
return (
<section
className="mt-12 pt-8 border-t border-gray-200"
aria-label="Footnotes"
>
<h2 className="text-sm font-bold mb-4">Notes</h2>
<ol className="space-y-3 text-sm text-gray-600 list-none pl-0 ml-0">
{footnotes.map((footnote) => (
<li
key={footnote.key}
id={`fn-${footnote.key}`}
className="flex gap-2"
>
<span className="font-bold text-gray-900 shrink-0">
{footnote.index}.
</span>
<span>
{typeof footnote.text === "string" ? (
parseMarkdownLinks(footnote.text)
) : Array.isArray(footnote.text) ? (
<FootnoteContent value={footnote.text} />
) : null}{" "}
<a
href={`#fnref-${footnote.key}`}
className="text-gray-400 hover:text-gray-900"
aria-label="Back to content"
>
</a>
</span>
</li>
))}
</ol>
</section>
);
}
/**
* Render footnote content (portable text)
*/
function FootnoteContent({ value }: { value: PortableTextBlock[] }) {
return (
<PortableText
value={value}
components={{
block: {
normal: ({ children }) => <span>{children}</span>,
},
marks: {
link: ({ value, children }) => {
const isExternal = value?.href?.startsWith("http");
return (
<a
href={value?.href}
className="underline"
{...(isExternal && {
target: "_blank",
rel: "noopener noreferrer",
})}
>
{children}
</a>
);
},
},
}}
/>
);
}
// Utility: extract footnotes from Portable Text blocks
import { PortableTextBlock } from "next-sanity";
export type Footnote = {
key: string;
index: number;
text: PortableTextBlock[] | string;
};
/**
* Extract all footnotes from portable text blocks.
* Footnotes are mark annotations with _type: "footnote" and a text field.
*/
export function extractFootnotes(blocks: PortableTextBlock[]): Footnote[] {
const footnotes: Footnote[] = [];
let index = 1;
blocks.forEach((block) => {
if (block._type === "block" && block.markDefs) {
block.markDefs
.filter((def: any) => def._type === "footnote")
.forEach((def: any) => {
// De-duplicate by key (same footnote can appear in multiple blocks)
if (!footnotes.find((f) => f.key === def._key)) {
footnotes.push({
key: def._key,
index: index++,
text: def.text || "",
});
}
});
}
});
return footnotes;
}
/**
* Get the footnote index for a given key
*/
export function getFootnoteIndex(
footnotes: Footnote[],
key: string
): number | undefined {
return footnotes.find((f) => f.key === key)?.index;
}

Footnotes in Sanity Portable Text + Next.js

A minimal implementation of footnotes as inline annotations in Sanity's Portable Text, rendered with bidirectional links in a Next.js frontend.

How it works

Footnotes are implemented as mark annotations (not separate blocks) on Portable Text. Each footnote annotation carries its own text field (using a simple Portable Text type for rich text support).

Schema side

  1. footnote — an object type used as a mark annotation inside bodyPortableText
  2. excerptPortableText — a simple Portable Text array used for the footnote text content
  3. bodyPortableText — the main content field, with footnote registered as an annotation alongside links

Frontend side

  1. footnotes.ts — extracts footnotes from Portable Text blocks by scanning markDefs, de-duplicates by _key, and assigns sequential indices
  2. footnotes.tsx — renders the "Notes" section at the bottom with backlinks ()
  3. portable-text.tsx — the footnote mark renders a superscript [n] link pointing to the footnote section

Data flow

Sanity Studio → author highlights text → adds footnote annotation with text
                                          ↓
Portable Text block.markDefs = [{ _type: "footnote", _key: "abc", text: [...] }]
                                          ↓
Frontend: extractFootnotes() scans markDefs → builds ordered list
                                          ↓
Inline: [n] superscript link (#fn-abc) ←→ Footnote section: backlink (#fnref-abc)

Dependencies

  • sanity (schema definitions)
  • @sanity/icons (editor icon)
  • next-sanity / @portabletext/react (rendering)

Usage

In a page component:

import { extractFootnotes } from './lib/footnotes'
import { Footnotes } from './components/footnotes'
import { CustomPortableText } from './components/portable-text'

export default async function PostPage({ params }) {
  const post = await fetchPost(params.slug)
  const footnotes = extractFootnotes(post.body)

  return (
    <article>
      <CustomPortableText value={post.body} footnotes={footnotes} />
      <Footnotes footnotes={footnotes} />
    </article>
  )
}
// Tests: footnote extraction utilities (Vitest)
import { describe, it, expect } from "vitest";
import { extractFootnotes, getFootnoteIndex } from "./footnotes";
import type { PortableTextBlock } from "next-sanity";
describe("extractFootnotes", () => {
it("should return empty array for empty blocks", () => {
const result = extractFootnotes([]);
expect(result).toEqual([]);
});
it("should return empty array for blocks without footnotes", () => {
const blocks: PortableTextBlock[] = [
{
_type: "block",
_key: "block1",
children: [{ _type: "span", text: "Hello world" }],
markDefs: [],
},
];
const result = extractFootnotes(blocks);
expect(result).toEqual([]);
});
it("should extract footnotes from block markDefs", () => {
const blocks: PortableTextBlock[] = [
{
_type: "block",
_key: "block1",
children: [
{ _type: "span", text: "Some text", marks: ["footnote1"] },
],
markDefs: [
{
_type: "footnote",
_key: "footnote1",
text: "This is a footnote",
},
],
},
];
const result = extractFootnotes(blocks);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
key: "footnote1",
index: 1,
text: "This is a footnote",
});
});
it("should extract multiple footnotes in order", () => {
const blocks: PortableTextBlock[] = [
{
_type: "block",
_key: "block1",
children: [{ _type: "span", text: "Text" }],
markDefs: [
{ _type: "footnote", _key: "fn1", text: "First note" },
{ _type: "footnote", _key: "fn2", text: "Second note" },
],
},
];
const result = extractFootnotes(blocks);
expect(result).toHaveLength(2);
expect(result[0].index).toBe(1);
expect(result[1].index).toBe(2);
});
it("should not duplicate footnotes with same key", () => {
const blocks: PortableTextBlock[] = [
{
_type: "block",
_key: "block1",
children: [{ _type: "span", text: "Text" }],
markDefs: [{ _type: "footnote", _key: "fn1", text: "Note" }],
},
{
_type: "block",
_key: "block2",
children: [{ _type: "span", text: "More text" }],
markDefs: [{ _type: "footnote", _key: "fn1", text: "Note" }],
},
];
const result = extractFootnotes(blocks);
expect(result).toHaveLength(1);
});
it("should ignore non-footnote markDefs", () => {
const blocks: PortableTextBlock[] = [
{
_type: "block",
_key: "block1",
children: [{ _type: "span", text: "Text" }],
markDefs: [
{ _type: "link", _key: "link1", href: "https://example.com" },
{ _type: "footnote", _key: "fn1", text: "Note" },
],
},
];
const result = extractFootnotes(blocks);
expect(result).toHaveLength(1);
expect(result[0].key).toBe("fn1");
});
});
describe("getFootnoteIndex", () => {
const footnotes = [
{ key: "fn1", index: 1, text: "First" },
{ key: "fn2", index: 2, text: "Second" },
{ key: "fn3", index: 3, text: "Third" },
];
it("should return correct index for existing footnote", () => {
expect(getFootnoteIndex(footnotes, "fn1")).toBe(1);
expect(getFootnoteIndex(footnotes, "fn2")).toBe(2);
expect(getFootnoteIndex(footnotes, "fn3")).toBe(3);
});
it("should return undefined for non-existent footnote", () => {
expect(getFootnoteIndex(footnotes, "fn99")).toBeUndefined();
});
it("should return undefined for empty array", () => {
expect(getFootnoteIndex([], "fn1")).toBeUndefined();
});
});
// Portable Text mark component: renders inline footnote references
// This is the mark handler for the "footnote" annotation type.
// Integrate this into your PortableText components configuration.
import { Footnote, extractFootnotes, getFootnoteIndex } from "./footnotes"; // adjust import path
// Type for the footnote mark value
interface FootnoteMarkValue {
_key: string;
_type: "footnote";
}
/**
* Creates PortableText components with footnote support.
* Call this with the extracted footnotes array.
*
* Usage:
* const footnotes = extractFootnotes(body)
* const components = createComponents(footnotes)
* <PortableText value={body} components={components} />
*/
function createComponents(footnotes: Footnote[]) {
return {
marks: {
footnote: ({
value,
children,
}: {
value?: FootnoteMarkValue;
children?: React.ReactNode;
}) => {
if (!value) return <>{children}</>;
const index = getFootnoteIndex(footnotes, value._key);
if (!index) return <>{children}</>;
return (
<>
{children}
<sup>
<a
id={`fnref-${value._key}`}
href={`#fn-${value._key}`}
className="text-gray-400 hover:text-gray-900 no-underline ml-0.5"
aria-describedby={`fn-${value._key}`}
>
[{index}]
</a>
</sup>
</>
);
},
// ... add your other mark handlers here (link, internalLink, etc.)
},
};
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment