Skip to main content
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:
  1. Plugin Integration: Installing and configuring the @sanity/table plugin
  2. Paste Handling: Intercepting paste events to convert HTML tables into Sanity's table format
  3. Cursor Position Detection: Inserting tables at the correct position in your content
  4. Frontend Rendering: Displaying tables properly in your Gatsby/Next.js site

Solution Overview

The solution uses:
  • The official @sanity/table plugin 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/uuid
The @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

  1. Paste Detection: The component listens for paste events and checks if the clipboard contains HTML with a <table> tag.
  2. Table Conversion: When a table is detected, it:
    • Parses the HTML to extract the table structure
    • Converts each row to Sanity's tableRow format with unique _key values
    • Creates a table block with the proper schema structure
  3. 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-key attributes
    • Falls back to the last tracked position if current selection can't be determined
  4. 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 _rawContent by 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:
  1. Your GraphQL query includes _rawContent
  2. The table component matches by _id
  3. The table data structure matches what the plugin expects (rows array with cells arrays)

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.

More Stories

Fixing a Dead Roborock S6 Charging Unit

My Roborock S6 suddenly stopped charging. No LED on the dock, no life signs. Naturally, first thought: power brick or cable. But those were fine, the issue was inside the dock itself. After cracki...