Developers & Technical · Guide · Developer Utilities
How to write clean markdown
Markdown flavors (CommonMark, GFM, MDX), heading and list rules, tables, links, GFM alerts, linting with markdownlint, common mistakes.
Markdown is the lingua franca of developer writing: READMEs, commit bodies, GitHub issues, docs sites, Obsidian notes, Notion pages, Substack, chat apps. It’s easy to write and hard to write well. Clean markdown renders consistently across platforms, diffs cleanly in git, and stays maintainable as it grows. Messy markdown breaks renderers, hides bugs in nested lists, and turns documents into git-diff nightmares. This guide covers the syntax (including the edge cases), flavor differences (CommonMark vs GFM vs others), style conventions, linting tools, and the patterns that separate readable markdown from the kind that works on your machine and nobody else’s.
Advertisement
Markdown flavors — what actually works
CommonMark: the standardized spec. Defines exactly what the core syntax means. Reference implementation for “portable” markdown.
GitHub Flavored Markdown (GFM): CommonMark + tables, strikethrough, task lists, autolinks, fenced code with language hints. What most developers actually write. Supported by GitHub, GitLab, many docs generators.
MDX: markdown + JSX components. Used by Next.js docs, Docusaurus, many modern docs sites. Breaks portability — MDX files don’t render on GitHub.
Pandoc markdown: very extended — footnotes, definition lists, citations, raw LaTeX. Popular for academic writing.
Obsidian markdown: GFM + wikilinks ([[like this]]) + embeds. Valid GFM but wikilinks break elsewhere.
Rule: target GFM unless you have a reason not to. It’s the broadest support with the features developers need.
Headings
ATX style (preferred): # H1, ## H2, etc. Clean, linter-friendly.
Setext style: underline with === or --- under the heading text. Only supports H1/H2. Harder to change levels.
One H1 per document. The H1 is usually the title. Start body content at H2.
Don’t skip levels. ## → #### is wrong. Accessibility and TOC generators both break.
Blank lines around headings. Required in strict CommonMark. Avoids accidental continuation into paragraphs.
Lists — the easy-to-mess-up part
Indentation: use 2 or 4 spaces consistently. GFM usually 2; some linters expect 4. Mixing breaks nesting.
Bullet consistency: pick one of -, *, +. Markdownlint enforces one.
Ordered lists: 1. 2. 3. works; so does 1. 1. 1. (renderers renumber). The all-1s style is easier to maintain because you don’t renumber when inserting items.
Blank line before lists. Required in strict CommonMark. Otherwise the list starts mid-paragraph and doesn’t render as a list.
Nested code in lists. Indent the code block to match the list item’s content column. Off-by-one indentation turns the code into regular text.
Code blocks
Fenced (preferred): ```language ... ```. Supports syntax highlighting via the language hint. Always specify the language when it’s code — even ```text for plain text blocks.
Indented: 4 spaces of indent marks a code block. CommonMark spec. Breaks inside lists.
Inline code: single backticks `like this`. If the code contains backticks, use double: ``code with `backticks` inside``.
Language hints: use standard names: js, ts, python, go, rust, bash, shell, sql, json, yaml, html, css, diff. Unknown languages fall through without highlighting.
Links and references
Inline: [text](url). Good for one-off links.
Reference: [text][ref] with [ref]: url at the bottom. Cleaner when the same URL appears multiple times, or URLs are long.
Autolinks (GFM): https://example.com auto-converts. Bare URLs become clickable.
Titles: [text](url "title") — shown on hover. Optional but useful for accessibility.
Image syntax: . Always include alt text for accessibility.
Relative vs absolute: in docs that get rebased (GitHub READMEs served at different paths), relative links break. Use absolute paths in published docs.
Tables (GFM)
Pipe-delimited with a header separator row:
| Column 1 | Column 2 | | -------- | -------- | | Row 1 | Data | | Row 2 | Data |
Alignment: :--- left, :---: center, ---: right in the separator row.
Don’t auto-align cells. Spending time aligning table cells with spaces is wasted — the renderer ignores it, and editing the table later re-breaks alignment.
Long cells: tables with very long cell content render poorly. Consider a definition list or prose if you have more than a short phrase per cell.
Blockquotes and callouts
Standard: > quoted text. Nested quotes: >>.
GFM alerts (new): GitHub added > [!NOTE], > [!WARNING], > [!TIP], > [!IMPORTANT], > [!CAUTION]. Renders as colored callout boxes on GitHub.
Line breaks and paragraphs
Paragraph: blank line between chunks of text.
Hard line break: two spaces at end of line →<br>. Easy to miss in code review (trailing whitespace). GFM also accepts \ at end of line.
Rule: hard breaks are usually wrong. If you want two separate lines, make them separate paragraphs. Hard breaks inside a paragraph are rare in technical writing.
Escaping
Backslash-escape markdown special characters to render them literally: \*not bold\* → *not bold*.
Common escape targets: *, _, #, <, >, [, ], \, \`.
Inside code blocks or inline code, no escaping needed. Everything is literal.
Linting with markdownlint
markdownlint-cli catches: heading spacing, list indentation mismatches, trailing whitespace, inconsistent bullets, bare URLs, duplicate headings, line length (if configured).
Add a .markdownlint.json to project root. Common overrides: disable line-length rule (MD013: false) since prose doesn’t need hard wrapping, and allow multiple H1s if needed (MD025: false).
Prettier also formats markdown. Combine: Prettier for formatting, markdownlint for rules.
Common mistakes
Trailing whitespace for line breaks. Invisible in diffs. Most editors strip it on save. Write as separate paragraphs instead.
Tight lists without blank lines. Breaks renderers. Always put a blank line before the first item.
Forgetting code block language. No highlighting, no copy-button support in many viewers. Always specify.
Deeply nested lists. Past 3 levels, markdown becomes unreadable. Refactor to headings or sub-sections.
Pasting rich text. Copying from Word or Notion often embeds HTML spans and non-breaking spaces. Paste as plain text, then format.
Markdown-in-HTML confusion. Inside a <div>, most renderers don’t parse markdown. Add a blank line inside the div, or use markdown="1" (Kramdown) or equivalent.
Run the numbers
Convert markdown to HTML instantly with the markdown to HTML converter. Pair with the HTML to markdown converter to extract content from web pages, and the HTML formatter to clean up the generated output.
Advertisement