Marked JS: turn Markdown into HTML in a single file, no npm

Here "marked" is the JavaScript library that converts Markdown text into HTML, not the HTML mark tag. The npm package is for projects with a build step. A plain HTML file loads the same library with one script tag.

To use marked in a web page, add one script tag that loads the library and call marked.parse(text). You do not need npm. The npm package is for projects with a build step. A plain HTML file fetches the same code from a CDN.

Here is the smallest working page. The Markdown sits in a script block, and three lines of JavaScript turn it into a heading, a list and a quote.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>marked in one file</title>
<style>
  body { margin: 0; padding: 16px; font: 16px/1.55 system-ui, sans-serif; color: #1d2330; }
  #out { max-width: 34rem; }
  #out h1 { font-size: 1.5rem; margin: 0 0 .5rem; }
  #out code { background: #eef1f5; border-radius: 4px; padding: 0 4px; }
  #out blockquote { margin: .8rem 0; padding: .1rem .9rem; border-left: 4px solid #cbd5e1; color: #475569; }
</style>
</head>
<body>
<div id="out"></div>

<!-- the Markdown lives in a data block: the browser never runs it -->
<script type="text/markdown" id="src">
# Hello from Markdown

This page was written as **Markdown** and turned into HTML by `marked.parse()`.

- one script tag loads the library
- one line converts the text

> No npm, no build step.
</script>

<!-- pinned version, global variable "marked" -->
<script src="https://cdn.jsdelivr.net/npm/marked@18.0.14/lib/marked.umd.js"></script>
<script>
  const md = document.getElementById('src').textContent;
  // text is ours, so no sanitizing is needed here
  document.getElementById('out').innerHTML = marked.parse(md);
</script>
</body>
</html>
The whole page: one data block, one library script, one line that converts and inserts.

Marked is an open-source Markdown parser written in JavaScript. Its own documentation describes it as a low-level markdown compiler with zero dependencies, and it works in a browser, on a server or from a command line.

npm install or a script tag?

The words "marked js npm" usually come from one question: do I have to install it? It depends on where your code runs.

Left: the npm form needs a build step. Right: one script tag in a plain HTML file.
Left: the npm form needs a build step. Right: one script tag in a plain HTML file.

The marked README gives npm install marked for use in a project. It then gives a browser example that loads a script from jsDelivr. Both routes run the same library.

A single HTML file has no build step. So the second route is the one to use. Marked ships a UMD build, which defines a global variable named marked as soon as the script loads.

Pin the version in the URL

The README example leaves the version out of the URL. That loads whatever is newest on the day the page opens. jsDelivr says this is not recommended for production, because major versions usually come with breaking changes.

Pin an exact version instead. On 2026-10-01 the latest version of marked on npm was 18.0.14:

<script src="https://cdn.jsdelivr.net/npm/marked@18.0.14/lib/marked.umd.js"></script>

Put that tag before any script that calls marked. A classic script is fetched and run in order, so code placed above the tag sees marked as undefined.

The ES module form, and the import that fails

Marked also ships an ES module file. In a plain page you may import it from a full URL, inside a script whose type is module:

<script type="module">
  import { marked } from 'https://cdn.jsdelivr.net/npm/marked@18.0.14/lib/marked.esm.js';
  document.body.innerHTML = marked.parse('# Hello');
</script>

The form that goes with npm install is import { marked } from 'marked'. It works when a bundler resolves the name.

A browser alone throws a TypeError, because a bare name needs an import map. MDN says so, and the error reads "Failed to resolve module specifier". The UMD tag avoids all of this.

Do not insert the output as it is

marked.parse() returns a string of HTML. Raw HTML inside the Markdown passes straight through into that string. The marked documentation says plainly that it does not sanitize the output, and recommends DOMPurify.

Three steps: Markdown text, marked.parse() output, then DOMPurify.sanitize() before innerHTML.
Three steps: Markdown text, marked.parse() output, then DOMPurify.sanitize() before innerHTML.

This matters as soon as the text is not yours. MDN calls innerHTML a common route for cross-site scripting. An img with a bad src and an onerror attribute runs its code the moment it is inserted.

The next example shows the strings as text, so nothing runs. Edit the Markdown and compare the two outputs.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>marked output vs DOMPurify</title>
<style>
  body { margin: 0; padding: 14px; font: 15px/1.5 system-ui, sans-serif; color: #1d2330; }
  h2 { font-size: .8rem; letter-spacing: .04em; text-transform: uppercase; margin: 14px 0 6px; color: #475569; }
  h2 b { font-weight: 700; }
  textarea { width: 100%; box-sizing: border-box; height: 96px; padding: 8px; font: 13px/1.4 ui-monospace, Consolas, monospace; border: 1px solid #cbd5e1; border-radius: 8px; }
  pre { margin: 0; padding: 8px 10px; border-radius: 8px; font: 12.5px/1.45 ui-monospace, Consolas, monospace; white-space: pre-wrap; word-break: break-all; }
  #raw { background: #fff1e6; border: 1px solid #f5b88a; }
  #clean { background: #e8f7ee; border: 1px solid #8fd1a8; }
  #view { padding: 8px 10px; border: 1px solid #e1e4ea; border-radius: 8px; }
  #note { font-size: 13px; color: #475569; margin: 6px 0 0; }
</style>
</head>
<body>
<h2>Markdown typed by a visitor</h2>
<textarea id="md" spellcheck="false">Hello **world**

<img src="x" onerror="alert('hacked')">

[click me](javascript:alert(1))</textarea>

<h2><b>1</b> · marked.parse() output (shown as text)</h2>
<pre id="raw"></pre>

<h2><b>2</b> · after DOMPurify.sanitize() (shown as text)</h2>
<pre id="clean"></pre>

<h2><b>3</b> · what the page renders (step 2 only)</h2>
<div id="view"></div>
<p id="note"></p>

<script src="https://cdn.jsdelivr.net/npm/marked@18.0.14/lib/marked.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.4.16/dist/purify.min.js"></script>
<script>
  const md = document.getElementById('md');

  function update() {
    const dirty = marked.parse(md.value);        // HTML string, NOT safe
    const clean = DOMPurify.sanitize(dirty);     // safe to insert
    // textContent shows the strings without running anything
    document.getElementById('raw').textContent = dirty;
    document.getElementById('clean').textContent = clean;
    document.getElementById('view').innerHTML = clean;
    document.getElementById('note').textContent =
      dirty === clean ? 'Nothing was removed.' : 'DOMPurify changed the HTML before it reached the page.';
  }

  md.addEventListener('input', update);
  update();
</script>
</body>
</html>
Orange: what marked returns. Green: what DOMPurify leaves. Only the green version reaches the page.

Load DOMPurify with its own pinned script tag, then wrap the call:

const dirty = marked.parse(text);
box.innerHTML = DOMPurify.sanitize(dirty);

If the Markdown is text you wrote yourself in the same file, the sanitize step is optional. For visitor text, a textarea, or anything pulled from elsewhere, keep it. See innerHTML for the other ways to put text on a page safely.

Options that change the output

Pass options as the second argument. Two matter most in a first project:

  • gfm is true by default. It follows the GitHub Flavored Markdown spec, which is why tables work.
  • breaks is false by default. Set it to true and a single line break inside a paragraph becomes a br tag.
marked.parse(text, { breaks: true });

There is also async, which defaults to false. With async: true, marked.parse() returns a Promise instead of a string. That mode exists for extensions that need to wait, so leave it off unless you use one.

The indentation trap

The most common surprise is a page that shows # Title as plain text in a grey box instead of a heading. The cause is leading spaces.

A template string indented four spaces turns into a code block. At the left edge it becomes a heading.
A template string indented four spaces turns into a code block. At the left edge it becomes a heading.

In Markdown, a line indented four or more spaces is an indented code block. If you write the Markdown inside a JavaScript template string and indent it to match your code, every line is code.

Two fixes. Keep the Markdown at the left edge, or use a type="text/markdown" script block as the first example does.

MDN says a script with a non-JavaScript type is a data block that the browser does not process. It stays plain text, and you read it with textContent.

A finished example: a page maker

This editor combines the pieces. Type Markdown on the left, see the page on the right. It sanitizes the output, builds a list of section links from the rendered headings, and has a breaks switch.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Markdown page maker</title>
<style>
  * { box-sizing: border-box; }
  body { margin: 0; padding: 12px; font: 15px/1.5 system-ui, sans-serif; color: #1d2330; background: #f6f7f9; }
  .bar { display: flex; flex-wrap: wrap; gap: 10px 14px; align-items: center; margin-bottom: 10px; }
  .bar label { font-size: 14px; }
  button { font: inherit; font-size: 14px; padding: 6px 12px; border: 1px solid #2563eb; background: #2563eb; color: #fff; border-radius: 8px; cursor: pointer; }
  .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
  @media (max-width: 640px) { .grid { grid-template-columns: 1fr; } }
  textarea { width: 100%; height: 250px; padding: 8px; font: 13px/1.4 ui-monospace, Consolas, monospace; border: 1px solid #cbd5e1; border-radius: 8px; resize: vertical; }
  #page { background: #fff; border: 1px solid #e1e4ea; border-radius: 8px; padding: 4px 14px 10px; height: 250px; overflow: auto; }
  #page h1 { font-size: 1.35rem; } #page h2 { font-size: 1.1rem; }
  #page code { background: #eef1f5; border-radius: 4px; padding: 0 4px; }
  #page table { border-collapse: collapse; } #page th, #page td { border: 1px solid #cbd5e1; padding: 3px 8px; }
  #toc { margin: 10px 0 0; font-size: 14px; }
  #toc a { margin-right: 12px; color: #1d4ed8; }
  #msg { font-size: 13px; color: #475569; margin: 8px 0 0; }
</style>
</head>
<body>
<div class="bar">
  <label><input type="checkbox" id="breaks"> Line breaks (<code>breaks: true</code>)</label>
  <button id="dl" type="button">Download as .html</button>
</div>

<div class="grid">
  <textarea id="md" spellcheck="false"># Trip notes

## Day 1
Landed at noon
checked in at three

## Packing list
| Item | Done |
| --- | --- |
| Passport | yes |
| Charger | no |

Use `marked.parse()` to turn this into HTML.</textarea>
  <div id="page"></div>
</div>
<div id="toc"></div>
<p id="msg"></p>

<script src="https://cdn.jsdelivr.net/npm/marked@18.0.14/lib/marked.umd.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.4.16/dist/purify.min.js"></script>
<script>
  const md = document.getElementById('md');
  const page = document.getElementById('page');
  const breaks = document.getElementById('breaks');
  let html = '';

  function render() {
    // parse with the chosen option, then sanitize before inserting
    html = DOMPurify.sanitize(marked.parse(md.value, { breaks: breaks.checked }));
    page.innerHTML = html;

    // table of contents from the rendered headings
    const toc = document.getElementById('toc');
    toc.textContent = '';
    page.querySelectorAll('h2').forEach((h, i) => {
      h.id = 'sec-' + i;
      const a = document.createElement('a');
      a.href = '#sec-' + i;
      a.textContent = h.textContent;
      a.addEventListener('click', (e) => { e.preventDefault(); h.scrollIntoView(); });
      toc.appendChild(a);
    });
  }

  // the downloaded file has the HTML baked in: no library needed to open it
  document.getElementById('dl').addEventListener('click', () => {
    const title = (page.querySelector('h1') || { textContent: 'Page' }).textContent;
    const doc = '<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n' +
      '<meta name="viewport" content="width=device-width, initial-scale=1">\n' +
      '<title>' + title.replace(/</g, '&lt;') + '</title>\n' +
      '<style>body{max-width:40rem;margin:2rem auto;padding:0 1rem;font:16px/1.6 system-ui}</style>\n' +
      '</head>\n<body>\n' + html + '\n</body>\n</html>\n';
    const a = document.createElement('a');
    a.href = URL.createObjectURL(new Blob([doc], { type: 'text/html' }));
    a.download = 'page.html';
    a.click();
    document.getElementById('msg').textContent = 'Saved page.html (' + doc.length + ' characters, no script tag inside).';
  });

  md.addEventListener('input', render);
  breaks.addEventListener('change', render);
  render();
</script>
</body>
</html>
Edit the Markdown, switch line breaks on and off, then download the result as a normal .html file.

Look at what the download button does. It takes the HTML string and writes it into a full page: doctype, title, viewport line and a small style block. The saved file contains no script tag. It opens anywhere, with or without the library.

That is the same idea as converting Markdown to HTML with a script. Do the conversion once and ship the result when the content does not change. Keep marked in the page when readers edit the text themselves.

When it does not work

Symptom Cause Fix
marked is not defined The tag is below your code, the URL is wrong, or the script was blocked Put the script tag above your code and check the Network tab
Failed to resolve module specifier A bare import from 'marked' in a browser Use the UMD script tag or a full URL
import gives a SyntaxError The script is not type="module" Add type="module" to that tag
Heading shows as grey code Lines indented four or more spaces Move the Markdown to the left edge
Markdown inside a div stays raw The div starts an HTML block that is not parsed Put blank lines after the opening tag and before the closing tag
Lines run together Single line breaks are not breaks Set breaks: true
Table shows as text gfm was set to false Leave gfm on, its default is true
Output is a Promise async: true was set Remove it, or use await
An image from another site is missing on the shared page Outside images are blocked there Use a data: URI or an inline SVG

A Markdown viewer is more useful when people can type into it. A screenshot cannot be edited, and an .html attachment may open as plain code, or not at all, on a phone. Opening an HTML file on a phone covers why.

To send the working version, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and the script from jsDelivr loads and runs. Anyone with the link can open it without an account and type their own Markdown. If you change the code later, the same link shows the new version.

Questions people ask

Do I need npm to use marked in an HTML page?

No. npm is one way to get the package into a project that has a build step. A plain HTML file can load the library from a CDN such as jsDelivr with a script tag, and then call marked.parse(). Nothing is installed.

Why does import { marked } from 'marked' fail in my HTML file?

A browser cannot resolve a bare name such as "marked" without an import map, and it throws a TypeError. Either use a full URL in a module script, or load the UMD file with a normal script tag and use the global marked.

Is marked safe to use with text from visitors?

Not by itself. The marked documentation states that it does not sanitize its output HTML and recommends a sanitizer such as DOMPurify. Run DOMPurify.sanitize() on the result before you give it to innerHTML.

How do I get line breaks to show?

In Markdown a single line break inside a paragraph is not a break. Pass breaks: true as an option, which adds a br tag at a single line break, or leave a blank line to start a new paragraph.

Can I save the converted result as a normal page?

Yes. marked.parse() returns a string of HTML. Put that string inside a body tag in a full page, save the file, and the saved page needs no script and no library to open.

Keep reading