Skip to content

Markdown Tables in React: react-markdown, MDX and JSON to Table

By MarkdownTables.com editorial teamUpdated

On this page

How do I render a Markdown table with react-markdown?

Pipe tables come from GitHub Flavored Markdown, an extension on top of CommonMark (see the Markdown table syntax guide). react-markdown implements CommonMark only, so without a plugin a table renders as a paragraph full of pipes. remark-gfm adds tables, strikethrough, task lists, footnotes and autolinks.

Shell
npm install react-markdown remark-gfm
Pricing.tsxTSX
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';

const markdown = `
| Plan  | Seats | Price |
| :---- | ----: | ----: |
| Team  |    10 |  $49  |
| Scale |   100 | $399  |
`;

export function Pricing() {
  return <Markdown remarkPlugins={[remarkGfm]}>{markdown}</Markdown>;
}
The Markdown in the component, and how it renders
Markdown
| Plan  | Seats | Price |
| :---- | ----: | ----: |
| Team  |    10 |  $49  |
| Scale |   100 | $399  |
Preview
PlanSeatsPrice
Team10$49
Scale100$399

Alignment colons become inline style="text-align:right" on each th and td, so they work without extra CSS.

Open in editor : The Markdown in the component, and how it renders

Both packages are ESM only. react-markdown 9 and 10 pair with remark-gfm@4; react-markdown 8 pairs with remark-gfm@3. Mixing majors (for example remark-gfm@4 with react-markdown 8) is a common reason tables still fail after installing the plugin, often with the error Cannot set properties of undefined (setting 'inTable').

Style tables and make them scroll

react-markdown outputs plain <table>, <thead>, <th> and <td> elements with no classes. Two changes make them usable: a wrapper that scrolls horizontally on narrow screens, and borders and padding. The components prop replaces any element with your own component:

MarkdownContent.tsxTSX
import Markdown, { type Components } from 'react-markdown';
import remarkGfm from 'remark-gfm';

const components: Components = {
  // Wrap every table so wide tables scroll instead of stretching the page
  table: ({ node, ...props }) => (
    <div className="table-wrapper" role="region" aria-label="Table" tabIndex={0}>
      <table {...props} />
    </div>
  ),
  th: ({ node, ...props }) => <th scope="col" {...props} />,
};

export function MarkdownContent({ markdown }: { markdown: string }) {
  return (
    <div className="markdown-body">
      <Markdown remarkPlugins={[remarkGfm]} components={components}>
        {markdown}
      </Markdown>
    </div>
  );
}

Destructure node out of the props before spreading them: every component receives the hast node, and passing it to a DOM element adds an invalid node attribute. React-markdown 10 also removed the className prop, which is why the classes go on a wrapping <div>.

GitHub-like table stylesCSS
.table-wrapper {
  overflow-x: auto;
  margin: 1rem 0;
}
.markdown-body table {
  border-collapse: collapse;
  min-width: 100%;
}
.markdown-body th,
.markdown-body td {
  border: 1px solid #d0d7de;
  padding: 6px 13px;
  vertical-align: top;
}
.markdown-body th {
  font-weight: 600;
  background: #f6f8fa;
}
.markdown-body tbody tr:nth-child(even) {
  background: #f6f8fa;
}

With Tailwind, either wrap the output in prose from @tailwindcss/typography (which styles tables already) or put utility classes on the mapped components:

Tailwind classes via componentsTSX
const components: Components = {
  table: ({ node, ...props }) => (
    <div className="my-6 overflow-x-auto">
      <table className="min-w-full border-collapse text-sm" {...props} />
    </div>
  ),
  th: ({ node, ...props }) => (
    <th scope="col" className="border border-slate-300 bg-slate-50 px-3 py-2 font-semibold" {...props} />
  ),
  td: ({ node, ...props }) => <td className="border border-slate-300 px-3 py-2 align-top" {...props} />,
};

If you'd rather not write CSS, the github-markdown-css package styles everything inside a .markdown-body element to look like GitHub.

HTML in cells (<br>) and sanitization

Markdown cells can't contain line breaks, so authors use <br> (see line breaks in tables). react-markdown escapes raw HTML by default, so this table shows a literal <br> until you opt in:

A cell with <br>: rendered with rehype-raw
Markdown
| Step    | Details                          |
| :------ | :------------------------------- |
| Install | `npm i`<br>then `npm run build` |
| Deploy  | Tag the release                  |
Preview
StepDetails
Installnpm i
then npm run build
DeployTag the release

Without rehype-raw, react-markdown prints the tag as text: npm i<br>then npm run build.

Open in editor : A cell with <br>: rendered with rehype-raw
Allow HTML safelyTSX
// npm install rehype-raw rehype-sanitize
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';

export function UserMarkdown({ markdown }: { markdown: string }) {
  return (
    <Markdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw, rehypeSanitize]}>
      {markdown}
    </Markdown>
  );
}

rehype-raw parses the HTML; rehype-sanitize must come after it and removes anything outside GitHub's allow-list, such as <script>, event handlers and javascript: URLs, while keeping <br>, tables and alignment. Skip sanitizing only when every author is trusted. Even without rehype-raw, react-markdown blanks out dangerous link URLs through its default urlTransform.

Tables in MDX, Next.js, Docusaurus and Nextra

MDX parses Markdown with the same remark tools, so the same rule applies: tables need remark-gfm unless the framework adds it for you.

Getting GFM tables in React-based doc frameworks
SetupTables out of the box?What to do
@mdx-js/mdx 3, @mdx-js/rollup, @mdx-js/loaderNoAdd remarkGfm to remarkPlugins
Next.js @next/mdxNoremarkPlugins: ['remark-gfm'] in createMDX
Docusaurus 3YesNothing; GFM is built in
NextraYesNothing; GFM is built in
react-markdown in any React appNoremarkPlugins={[remarkGfm]}
Next.js: next.config.mjsJavaScript
// next.config.mjs
import createMDX from '@next/mdx';

const withMDX = createMDX({
  options: {
    // String form works with Turbopack; with webpack you can also import remarkGfm
    remarkPlugins: ['remark-gfm'],
  },
});

export default withMDX({
  pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'],
});

Next.js documents the string form ('remark-gfm') for Turbopack, because functions can't be passed to its Rust side; with webpack you can import the plugin and pass the function. The config file must be .mjs or .ts because the plugins are ESM only. Style tables for every MDX page in mdx-components.tsx:

mdx-components.tsxTSX
// mdx-components.tsx (required by @next/mdx in the App Router)
import type { MDXComponents } from 'mdx/types';

const components: MDXComponents = {
  table: props => (
    <div className="table-wrapper">
      <table {...props} />
    </div>
  ),
};

export function useMDXComponents(): MDXComponents {
  return components;
}

In any MDX file, HTML is JSX. Write <br /> instead of <br>, and className instead of class; an unclosed <br> is a compile error, not a rendering glitch.

markdown-it and marked

Outside React components (API routes, build scripts, or apps that inject HTML), markdown-it and marked are the usual choices. Both produce an HTML string.

markdown-it with a scroll wrapperTypeScript
import MarkdownIt from 'markdown-it';

const md = new MarkdownIt(); // 'default' preset: tables on, raw HTML off
md.renderer.rules.table_open = () => '<div class="table-wrapper"><table>\n';
md.renderer.rules.table_close = () => '</table></div>\n';

const html = md.render(source);

markdown-it's default preset includes tables; the commonmark preset doesn't, and md.enable('table') turns them back on. Raw HTML is off unless you pass { html: true }.

marked plus DOMPurifyTSX
import { marked } from 'marked';
import DOMPurify from 'dompurify';

// GFM, including tables, is on by default. marked does not sanitize.
const html = DOMPurify.sanitize(marked.parse(source, { async: false }));

export function Rendered() {
  return <div className="markdown-body" dangerouslySetInnerHTML={{ __html: html }} />;
}

marked passes raw HTML straight through, so always sanitize its output before dangerouslySetInnerHTML. { async: false } makes TypeScript type the result as a string. Note that marked writes alignment as align="right" attributes, while markdown-it and react-markdown use inline styles; target both in CSS if you switch renderers.

Table support by library

Markdown table support in JavaScript renderers
Library / frameworkTables by defaultHTML in cellsSafe with untrusted input
react-markdown 10CommonMark only until you add remark-gfm.No: Add remark-gfmPartly: rehype-raw neededYes: Raw HTML escaped
MDX 3 (@mdx-js, @next/mdx)HTML is parsed as JSX: write <br />.No: Add remark-gfmYes: JSX <br />Not applicable: Content is code
Docusaurus 3MDX with GFM built in.YesYes: JSX <br />Not applicable: Content is code
NextraMDX with GFM built in.YesYes: JSX <br />Not applicable: Content is code
markdown-itdefault preset; the commonmark preset has tables off.YesPartly: html: true optionPartly: HTML off by default; no sanitizer
markedgfm: true is the default.YesYesNo: Sanitize with DOMPurify

✅ supported · ⚠️ partly or with a workaround · ❌ not supported · Last verified October 2026

“Content is code”: MDX compiles to JavaScript, so only render MDX you trust. Use react-markdown for user-supplied Markdown.

JSON to Markdown table in JavaScript

Generating the Markdown, rather than rendering it, is a common need for READMEs, PR comments and changelogs. A small function covers it. The two details most snippets miss: use the union of all keys as columns, and escape | and newlines so a value can't break the row.

jsonToMarkdownTable.tsTypeScript
type Align = 'left' | 'center' | 'right';

export function jsonToMarkdownTable(
  rows: Record<string, unknown>[],
  align: Record<string, Align> = {},
): string {
  // Union of keys, in first-seen order, so objects with different fields still line up
  const columns = [...new Set(rows.flatMap(row => Object.keys(row)))];

  const cell = (value: unknown): string => {
    if (value === null || value === undefined) return '';
    const text = typeof value === 'object' ? JSON.stringify(value) : String(value);
    return text.replace(/\|/g, '\\|').replace(/\r?\n/g, '<br>');
  };

  const delimiter = (col: string): string => {
    switch (align[col]) {
      case 'left': return ':---';
      case 'center': return ':---:';
      case 'right': return '---:';
      default: return '---';
    }
  };

  return [
    `| ${columns.map(cell).join(' | ')} |`,
    `| ${columns.map(delimiter).join(' | ')} |`,
    ...rows.map(row => `| ${columns.map(col => cell(row[col])).join(' | ')} |`),
  ].join('\n');
}

const users = [
  { id: 1, name: 'Ada', role: 'Engineer' },
  { id: 2, name: 'Grace', role: 'Architect', team: 'Compiler' },
];
console.log(jsonToMarkdownTable(users, { id: 'right' }));
Output: missing fields become empty cells
Markdown
| id | name | role | team |
| ---: | --- | --- | --- |
| 1 | Ada | Engineer |  |
| 2 | Grace | Architect | Compiler |
Preview
idnameroleteam
1AdaEngineer
2GraceArchitectCompiler

If you prefer a dependency, these two are common. Their defaults differ in ways that matter:

markdown-table and tablemarkTypeScript
// npm install markdown-table   (wooorm; arrays of rows, no escaping)
import { markdownTable } from 'markdown-table';

const columns = ['name', 'role', 'score'];
markdownTable(
  [columns, ...data.map(row => columns.map(c => String(row[c] ?? '')))],
  { align: ['l', 'l', 'r'] },
);

// npm install tablemark   (arrays of objects; escapes pipes)
import { tablemark } from 'tablemark';

tablemark(data, {
  columns: ['Name', 'Role', { name: 'Score', align: 'right' }],
  lineBreakStrategy: 'strip', // the default 'preserve' wraps the row onto extra lines
});
JavaScript libraries that generate Markdown tables
PackageInputEscapes `|`Columns fromNotes
markdown-table (wooorm)Array of row arraysNoYou pass the header rowPads cells; align: ['l', 'c', 'r']
tablemarkArray of objectsYesFirst object’s keys onlySentence-cases headers by default; set lineBreakStrategy
Function aboveArray of objectsYes, plus newlinesUnion of all keysNo padding; renders the same

To convert without code, paste JSON into the JSON to Markdown converter. For the same job in Python, see Markdown tables in Python.

Server Components, SSR and performance

  • Server Components. The default Markdown export is synchronous and has no hooks, so it renders in a Server Component (no "use client") and ships no Markdown parser to the browser.
  • Async plugins. Use MarkdownAsync on the server or MarkdownHooks on the client (both since react-markdown 9.1) when a plugin, such as some syntax highlighters, is async.
  • Memoize client renders. Parsing runs on every render. For large documents in client components, keep the components object and plugin arrays outside the component (as above) so they aren't recreated, and memoize the rendered output if the parent re-renders often.
  • Static content. If the Markdown is known at build time, compile it with MDX or render it once at build time instead of parsing it in the browser.

Accessible tables from Markdown

GFM tables give you <thead> and <th> cells but nothing else. Add the rest through components:

  • scope="col" on header cells (shown in the th mapping above).
  • A focusable scroll region (tabIndex={0}, role="region" and an aria-label) so keyboard users can scroll a wide table.
  • A heading or sentence before the table, since GFM has no caption (see captions).
  • Words, not only icons, in status cells, and no empty header cells.

Common mistakes and fixes

Symptoms, causes and fixes
SymptomCauseFix
Pipes show as a paragraphNo remark-gfmAdd remarkPlugins={[remarkGfm]}
Cannot set properties of undefined (setting 'inTable')remark-gfm and react-markdown majors don’t matchremark-gfm 4 for react-markdown 9/10; 3 for 8
ERR_REQUIRE_ESMrequire() of an ESM-only packageUse import; rename config to .mjs
<br> shows as textHTML escaped by defaultrehype-raw + rehype-sanitize
MDX compile error at <br>HTML is JSX in MDXWrite <br />
Table overflows on mobileNo scroll containerWrap table in an overflow-x: auto element
node="[object Object]" attributeProps spread without removing nodeDestructure { node, ...props }
Generated table has an extra columnUnescaped | in a valueEscape as \| before joining

If the source table itself is the problem, the table validator points to the line, and why a table won't render covers renderer-specific causes.

Convert a Markdown table to HTML

Frequently asked questions

Why is my react-markdown table not rendering?

Tables are a GitHub Flavored Markdown extension, and react-markdown only supports CommonMark by default. Install remark-gfm and pass it: <Markdown remarkPlugins={[remarkGfm]}>. If it still fails, check the table itself: the delimiter row must have as many cells as the header.

Which remark-gfm version works with react-markdown 9 and 10?

remark-gfm 4. Both react-markdown 9 and 10 use the unified 11 ecosystem, and both packages are ESM only, so import them with import, not require.

How do I style a react-markdown table?

Map table in the components prop to your own component, for example a <div className="overflow-x-auto"> wrapper around <table {...props} />, and style th and td with CSS or Tailwind. react-markdown 10 removed the className prop, so put classes on a wrapping element.

How do I use <br> in a react-markdown table cell?

Add rehype-raw to render HTML, and rehype-sanitize after it if the Markdown is not fully trusted: rehypePlugins={[rehypeRaw, rehypeSanitize]}. Without them, <br> shows as literal text.

How do I convert JSON to a Markdown table in JavaScript?

Collect the keys of all objects as columns, write | col | col | and | --- | --- |, then one row per object, escaping | as \| and newlines as <br>. Libraries such as tablemark and markdown-table do this too, or paste the JSON into the JSON to Markdown converter.

Do MDX and Docusaurus support Markdown tables?

Docusaurus and Nextra support them out of the box. Plain MDX 2 and 3, including @next/mdx, need remark-gfm in remarkPlugins. In all MDX files, HTML is JSX, so write <br /> rather than <br>.

Can react-markdown run in a React Server Component?

Yes. The default Markdown export is synchronous and uses no hooks, so it renders on the server without "use client". Use MarkdownAsync on the server or MarkdownHooks on the client when a plugin is async.

Related: Markdown tables in Python, GitHub README tables and formatting inside cells.