Rich text editors often lack built-in table support and Sanity Studio's Portable Text editor is no exception. However, with the right approach, you can add full table functionality including paste support from external sources like Excel or Google Sheets. This guide walks through a complete implementation that handles table creation, editing and rendering.
The Challenge
When working with Sanity Studio's Portable Text editor, adding table support requires several components:
- Plugin Integration: Installing and configuring the
@sanity/tableplugin - Paste Handling: Intercepting paste events to convert HTML tables into Sanity's table format
- Cursor Position Detection: Inserting tables at the correct position in your content
- Frontend Rendering: Displaying tables properly in your Gatsby/Next.js site
Solution Overview
The solution uses:
- The official
@sanity/tableplugin for table editing capabilities - Custom paste handling to convert HTML tables on paste
- DOM-based cursor tracking to insert tables at the correct position
- Tailwind CSS styling for frontend rendering
Step 1: Install the Table Plugin
First, install the required dependencies:
npm install @sanity/table @sanity/uuidThe
@sanity/table package provides table editing components, and @sanity/uuid is needed to generate unique keys for table rows.Step 2: Configure the Plugin in Sanity Config
Add the table plugin to your
sanity.config.ts:import { table } from '@sanity/table';
import { defineConfig } from 'sanity';
export default defineConfig({
// ... other config
plugins: [
// ... other plugins
table(),
],
// ...
});This automatically registers the
table and tableRow schema types that we'll use in our content schema.Step 3: Add Table to Your Content Schema
In your content schema (e.g.,
post.ts), add table as one of the allowed types in your Portable Text array:import { defineField, defineType } from 'sanity';
import BodyPortableTextInput from '../components/BodyPortableTextInput';
import { TablePreview } from '@sanity/table';
export default defineType({
name: 'post',
title: 'Post',
type: 'document',
fields: [
// ... other fields
defineField({
name: 'content',
title: 'Content',
components: {
input: BodyPortableTextInput, // Custom input component
preview: TablePreview,
},
type: 'array',
of: [
{
type: 'block',
// ... block configuration
},
{ type: 'table' }, // Add table type here
// ... other types (images, quotes, etc.)
],
}),
],
});Step 4: Create Custom Paste Handler
The magic happens in a custom
PortableTextInput component that intercepts paste events. Create components/BodyPortableTextInput.tsx:import { TablePreview } from '@sanity/table';
import { uuid } from '@sanity/uuid';
import React, { useEffect, useRef } from 'react';
import { PortableTextInput, set } from 'sanity';
export default function BodyPortableTextInput(props: any) {
const containerRef = useRef<HTMLDivElement>(null);
const lastBlockIndexRef = useRef<number | null>(null);
// Shared function to find block index from DOM selection
const findBlockIndexFromSelection = (
container: HTMLElement,
currentValue: any[]
): number | null => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
const editorElement = container.querySelector('[contenteditable="true"]');
if (!editorElement) return null;
// Walk up the DOM tree to find the block container
let node: Node | null = range.startContainer;
while (node && node !== editorElement) {
const element: Element | null =
node.nodeType === Node.ELEMENT_NODE
? (node as Element)
: node.parentElement;
if (!element) break;
// PortableText blocks typically have a data-key attribute
const blockKey =
element.getAttribute('data-key') ||
element.getAttribute('data-block-key');
if (blockKey) {
const blockIndex = currentValue.findIndex(
(block: any) => block?._key === blockKey
);
if (blockIndex !== -1) {
return blockIndex;
}
}
node = element.parentNode;
}
return null;
};
// Combined effect for focus tracking and paste handling
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const currentValue = props.value || [];
// Track which block the user is currently focused on
const handleFocus = () => {
const blockIndex = findBlockIndexFromSelection(container, currentValue);
if (blockIndex !== null) {
lastBlockIndexRef.current = blockIndex;
}
};
// Handle table paste
const handlePaste = (e: ClipboardEvent) => {
const html = e.clipboardData?.getData('text/html');
// Check if this is a table paste
if (!html || !/<table/i.test(html)) {
return;
}
// Find the PortableText editor element
const editorElement = container.querySelector('[contenteditable="true"]');
if (!editorElement) return;
// Prevent default paste behavior completely
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const dom = new DOMParser().parseFromString(html, 'text/html');
const tableEl = dom.querySelector('table');
if (!tableEl) {
return;
}
// Create rows with _key for each row
const rows = Array.from(tableEl.rows).map((tr) => ({
_type: 'tableRow',
_key: uuid(),
cells: Array.from(tr.cells).map((td) => td.textContent ?? ''),
}));
// Create table block with _key
const tableBlock = {
_type: 'table',
_key: uuid(),
rows,
};
// Try to find block index from current selection, fall back to tracked index
let insertIndex = currentValue.length;
const blockIndex = findBlockIndexFromSelection(container, currentValue);
if (blockIndex !== null) {
insertIndex = blockIndex + 1;
} else if (lastBlockIndexRef.current !== null) {
insertIndex = lastBlockIndexRef.current + 1;
}
// Ensure insertIndex is within bounds
insertIndex = Math.min(Math.max(0, insertIndex), currentValue.length);
// Insert the table block at the determined position
const newValue = [...currentValue];
newValue.splice(insertIndex, 0, tableBlock);
props.onChange(set(newValue));
};
// Attach event listeners
container.addEventListener('click', handleFocus);
container.addEventListener('keydown', handleFocus);
container.addEventListener('paste', handlePaste, true);
return () => {
container.removeEventListener('click', handleFocus);
container.removeEventListener('keydown', handleFocus);
container.removeEventListener('paste', handlePaste, true);
};
}, [props]);
const onPaste = React.useCallback((evt: { event: React.ClipboardEvent }) => {
// Fallback handler to prevent default behavior
const html = evt.event.clipboardData?.getData('text/html');
if (!html || !/<table/i.test(html)) {
return;
}
evt.event.preventDefault();
evt.event.stopPropagation();
}, []);
return (
<div ref={containerRef}>
<PortableTextInput
{...props}
onPaste={onPaste}
/>
</div>
);
}How It Works
- Paste Detection: The component listens for paste events and checks if the clipboard contains HTML with a
<table>tag. - Table Conversion: When a table is detected, it:
- Parses the HTML to extract the table structure
- Converts each row to Sanity's
tableRowformat with unique_keyvalues - Creates a table block with the proper schema structure
- Cursor Position Tracking:
- Tracks which block the user is focused on by listening to click and keydown events
- Uses DOM traversal to find the block containing the cursor by looking for
data-keyattributes - Falls back to the last tracked position if current selection can't be determined
- Smart Insertion: Inserts the table right after the block where the cursor is positioned, not just at the end.
Step 5: Render Tables on the Frontend
In your Gatsby/Next.js template (e.g.,
templates/post.js), add table rendering to your Portable Text components:import {PortableText} from '@portabletext/react'
const portableTextComponents = {
types: {
// ... other types
table: ({value}) => {
// Find the table in _rawContent by matching _id or _key
const tableData = post._rawContent.find(
(item) =>
item._type === 'table' &&
((value._id &&
(item._id === value._id || item._key === value._id)) ||
(value._key &&
(item._key === value._key || item._id === value._key)))
)
if (!tableData || !tableData.rows || tableData.rows.length === 0) {
return null
}
// Use first row as header, rest as body
const [headerRow, ...bodyRows] = tableData.rows
return (
<div className="my-8 md:my-12 prose prose-sm md:prose-base max-w-none w-full">
<div className="overflow-x-auto">
<table className="w-full border-collapse border border-gray-300">
<thead>
<tr className="bg-gray-100">
{headerRow.cells?.map((cell, index) => (
<th
key={`header-${headerRow._key || headerRow._id || index}-${index}`}
className="border border-gray-300 px-4 py-3 text-left font-semibold text-gray-900"
>
{cell || ''}
</th>
))}
</tr>
</thead>
<tbody>
{bodyRows.map((row, rowIndex) => (
<tr
key={`row-${row._key || row._id || rowIndex}`}
className="bg-white even:bg-gray-50 hover:bg-gray-100 transition-colors"
>
{row.cells?.map((cell, cellIndex) => (
<td
key={`cell-${row._key || row._id || rowIndex}-${cellIndex}`}
className="border border-gray-300 px-4 py-3 text-gray-700"
>
{cell || ''}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)
},
},
// ... other component types
}
// Usage
<PortableText value={post.content} components={portableTextComponents} />Frontend Rendering Details
- Data Lookup: The component looks up the full table data from
_rawContentby matching_id - Header Row: The first row is automatically treated as the table header
- Styling: Uses Tailwind CSS with prose classes for consistent typography
- Responsive: Includes horizontal scrolling for mobile devices
- Accessibility: Proper semantic HTML with
<thead>and<tbody>elements
Key Features
Paste Support: Paste tables directly from Excel, Google Sheets, or any HTML source
Smart Insertion: Tables are inserted at your cursor position, not just at the end
Visual Editor: Use the table menu in Sanity Studio to add/remove rows and columns
Proper Rendering: Tables render correctly on the frontend with Tailwind styling
Schema Validation: Tables are properly typed and validated in Sanity's schema
Common Issues & Solutions
Tables Appear at the End Instead of Cursor Position
The DOM-based cursor tracking might not always work perfectly. The component falls back to the last tracked position, which should be accurate if you've clicked or typed in the editor recently.
Duplicate Content on Paste
Make sure you're preventing default paste behavior with
e.preventDefault(), e.stopPropagation(), and e.stopImmediatePropagation() in the capture phase (true as the third parameter to addEventListener).Tables Not Rendering on Frontend
Ensure:
- Your GraphQL query includes
_rawContent - The table component matches by
_id - The table data structure matches what the plugin expects (
rowsarray withcellsarrays)
Conclusion
This implementation provides a complete table solution for Sanity Studio that handles both editing and rendering. The paste handler makes it easy for content editors to add tables from external sources, while the cursor tracking ensures tables appear exactly where they're needed.
The solution is production-ready and handles edge cases gracefully. With this setup, your content team can seamlessly work with tables in their blog posts and pages.


