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.
npm install react-markdown remark-gfmimport 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>;
}| Plan | Seats | Price |
| :---- | ----: | ----: |
| Team | 10 | $49 |
| Scale | 100 | $399 || Plan | Seats | Price |
|---|---|---|
| Team | 10 | $49 |
| Scale | 100 | $399 |
Alignment colons become inline style="text-align:right" on each th and td, so they work without extra CSS.
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:
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>.
.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:
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:
<br>: rendered with rehype-raw| Step | Details |
| :------ | :------------------------------- |
| Install | `npm i`<br>then `npm run build` |
| Deploy | Tag the release || Step | Details |
|---|---|
| Install | npm ithen npm run build |
| Deploy | Tag the release |
Without rehype-raw, react-markdown prints the tag as text: npm i<br>then npm run build.
// 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.
| Setup | Tables out of the box? | What to do |
|---|---|---|
@mdx-js/mdx 3, @mdx-js/rollup, @mdx-js/loader | No | Add remarkGfm to remarkPlugins |
Next.js @next/mdx | No | remarkPlugins: ['remark-gfm'] in createMDX |
| Docusaurus 3 | Yes | Nothing; GFM is built in |
| Nextra | Yes | Nothing; GFM is built in |
| react-markdown in any React app | No | remarkPlugins={[remarkGfm]} |
// 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.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.
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 }.
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
| Library / framework | Tables by default | HTML in cells | Safe with untrusted input |
|---|---|---|---|
react-markdown 10CommonMark only until you add remark-gfm. | No: Add remark-gfm | Partly: rehype-raw needed | Yes: Raw HTML escaped |
MDX 3 (@mdx-js, @next/mdx)HTML is parsed as JSX: write <br />. | No: Add remark-gfm | Yes: JSX <br /> | Not applicable: Content is code |
| Docusaurus 3MDX with GFM built in. | Yes | Yes: JSX <br /> | Not applicable: Content is code |
| NextraMDX with GFM built in. | Yes | Yes: JSX <br /> | Not applicable: Content is code |
markdown-itdefault preset; the commonmark preset has tables off. | Yes | Partly: html: true option | Partly: HTML off by default; no sanitizer |
markedgfm: true is the default. | Yes | Yes | No: 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.
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' }));| id | name | role | team |
| ---: | --- | --- | --- |
| 1 | Ada | Engineer | |
| 2 | Grace | Architect | Compiler || id | name | role | team |
|---|---|---|---|
| 1 | Ada | Engineer | |
| 2 | Grace | Architect | Compiler |
If you prefer a dependency, these two are common. Their defaults differ in ways that matter:
// 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
});| Package | Input | Escapes `|` | Columns from | Notes |
|---|---|---|---|---|
markdown-table (wooorm) | Array of row arrays | No | You pass the header row | Pads cells; align: ['l', 'c', 'r'] |
tablemark | Array of objects | Yes | First object’s keys only | Sentence-cases headers by default; set lineBreakStrategy |
| Function above | Array of objects | Yes, plus newlines | Union of all keys | No 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
Markdownexport 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
MarkdownAsyncon the server orMarkdownHookson 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
componentsobject 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 thethmapping above).- A focusable scroll region (
tabIndex={0},role="region"and anaria-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
| Symptom | Cause | Fix |
|---|---|---|
| Pipes show as a paragraph | No remark-gfm | Add remarkPlugins={[remarkGfm]} |
Cannot set properties of undefined (setting 'inTable') | remark-gfm and react-markdown majors don’t match | remark-gfm 4 for react-markdown 9/10; 3 for 8 |
ERR_REQUIRE_ESM | require() of an ESM-only package | Use import; rename config to .mjs |
<br> shows as text | HTML escaped by default | rehype-raw + rehype-sanitize |
MDX compile error at <br> | HTML is JSX in MDX | Write <br /> |
| Table overflows on mobile | No scroll container | Wrap table in an overflow-x: auto element |
node="[object Object]" attribute | Props spread without removing node | Destructure { node, ...props } |
| Generated table has an extra column | Unescaped | in a value | Escape 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.
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.