Next.js and HTML: from a Next.js page to a plain HTML file

Next.js writes HTML for you, but it does not hand you a single file. Here is where the HTML comes from, how to export it, and how to rebuild a small page as one file that runs anywhere.

Next.js is a React framework: you write pages as React components, and Next.js turns them into HTML.

That makes "Next.js HTML" mean two things. You either want the HTML files Next.js produces, or you want a Next.js page as one plain HTML file. This guide covers both.

The short answers. Next.js can export static HTML with output: 'export', but the result is a folder, not a file. A small page is often easier to rebuild as plain HTML. Here is a Next.js docs counter, rebuilt that way:

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>Counter in plain HTML</title>
<style>
  body { margin: 0; padding: 20px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .card { max-width: 360px; background: #fff; border-radius: 12px; padding: 18px 20px; box-shadow: 0 4px 16px rgba(0, 0, 0, .08); }
  h1 { font-size: 20px; margin: 0 0 6px; }
  p { margin: 0 0 14px; color: #4b5563; }
  button { font: inherit; padding: 9px 16px; border: 0; border-radius: 8px; background: #111827; color: #fff; cursor: pointer; }
  button.ghost { background: #e5e7eb; color: #111827; margin-left: 6px; }
  #count { font-weight: 700; color: #111827; }
</style>
</head>
<body>
<!-- The HTML that Next.js would prerender from page.tsx -->
<main class="card">
  <h1>Hello, Next.js!</h1>
  <p><span id="count">0</span> likes</p>
  <button id="like" type="button">Click me</button>
  <button id="reset" class="ghost" type="button">Reset</button>
</main>

<script>
  // useState(0) becomes a plain variable
  let count = 0;
  const out = document.getElementById('count');

  // setCount(...) becomes: change the variable, then update the text
  function render() { out.textContent = count; }

  // onClick={...} becomes addEventListener('click', ...)
  document.getElementById('like').addEventListener('click', () => { count++; render(); });
  document.getElementById('reset').addEventListener('click', () => { count = 0; render(); });
</script>
</body>
</html>
The counter component from the Next.js docs as plain HTML, with a Reset button added. Edit it and it reruns.

No React, no build, no server. The file opens on its own, which is what you need to email it, preview it or share it as a link.

Where the HTML in a Next.js app comes from

A Next.js project has no index.html to edit. With the App Router, each route is a folder under app/, and the page is a component in page.tsx. The outer document comes from the root layout, app/layout.tsx.

The root layout gives the html and body tags, metadata fills the head, and the page fills the body.
The root layout gives the html and body tags, metadata fills the head, and the page fills the body.
// app/layout.tsx
export const metadata = { title: 'Corner Cafe' }

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  )
}

The Next.js docs say the root layout must define <html> and <body>. They also say not to write <title> and <meta> tags into it by hand. Use the metadata export instead, which fills the <head>.

By default, layouts and pages are Server Components. They are rendered to HTML ahead of time. Anything with state or click handlers must be a Client Component, marked with 'use client' at the top of its file.

Export static HTML with output: 'export'

To get HTML files out of Next.js, turn on static export in next.config.js:

// next.config.js
const nextConfig = {
  output: 'export',
}

module.exports = nextConfig

Then run next build. Next.js writes an out folder with one HTML file per route, for example out/index.html and out/404.html. The JavaScript and CSS go under _next/static/ in the same folder.

The project folder on the left, the exported out folder on the right. The HTML files still need the _next folder next to them.
The project folder on the left, the exported out folder on the right. The HTML files still need the _next folder next to them.

The Next.js docs say the export can be hosted on any web server that serves static HTML, CSS and JavaScript. Some features need a running server and do not work in an export:

Needs a server, so not in a static export What to do instead
Cookies and Route Handlers that read the request Read data in the browser, or keep a server
Rewrites, redirects and custom headers in next.config Configure them on the host
Server Actions Send forms to a separate API
Image Optimization with the default loader Set a custom image loader
Incremental Static Regeneration Rebuild and upload again

Two things trip people up. First, next build needs the project and Node.js; the docs list Node.js 20.9 as the minimum.

Second, the asset paths in the exported pages start with /_next/. Opened from disk, a path starting with / points at the root of the drive, not the out folder, so serve the folder instead.

When one HTML file is the better target

An export keeps your whole Next.js app working. But sometimes the job is smaller: a landing section, a calculator, a prototype for a client. Then a single .html file is easier to send, open and edit than a project and a build.

The rewrite is mechanical. Server Components render to plain markup, so copy it as it is. Client Components need a few lines of script each.

  1. Copy the rendered HTML into the <body> of a new file.
  2. Rename JSX attributes: className to class, htmlFor to for, style objects to CSS strings.
  3. Replace state with variables. Each useState value becomes a variable, plus a function that writes it into the page.
  4. Replace event props with listeners. Each onClick becomes addEventListener('click', ...) in a <script>.
  5. Open the file and test it. No build step, no server.

This is the docs counter before the rewrite. The demo at the top is the same component after it:

'use client'
import { useState } from 'react'

export default function Counter() {
  const [count, setCount] = useState(0)
  return (
    <div>
      <p>{count} likes</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  )
}

JSX is not HTML: the attributes that change

JSX looks like HTML, so pasting it into an .html file is the most common first attempt. The browser shows it, but some attributes are silently ignored and the event handlers never run.

The same markup in JSX and in plain HTML. The left side only works through React.
The same markup in JSX and in plain HTML. The left side only works through React.

React uses DOM property names, so a CSS class is className and a label's target is htmlFor. style takes an object with camelCase names, such as fontWeight. Event props such as onClick take a function, not a string of code.

Paste a JSX snippet below and convert it. The converter handles the common cases from the table above, not every piece of JSX:

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>JSX to HTML attribute fixer</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  label { display: block; font-weight: 600; margin: 0 0 6px; }
  textarea { box-sizing: border-box; width: 100%; height: 150px; font: 13px/1.45 ui-monospace, Consolas, monospace; padding: 10px; border: 1px solid #cfd4dc; border-radius: 8px; resize: vertical; }
  button { font: inherit; margin: 10px 0 14px; padding: 9px 16px; border: 0; border-radius: 8px; background: #111827; color: #fff; cursor: pointer; }
  ul { margin: 10px 0 0; padding-left: 20px; font-size: 14px; color: #374151; }
  li.warn { color: #9a3412; }
</style>
</head>
<body>
<label for="jsx">JSX from a Next.js page</label>
<textarea id="jsx" spellcheck="false"><div className="card" style={{ fontWeight: 'bold', marginTop: 8 }}>
  {/* a comment */}
  <label htmlFor="email">Email</label>
  <input id="email" type="email" />
  <button onClick={() => send()}>Send</button>
</div></textarea>
<button id="go" type="button">Convert to HTML</button>
<label for="html">HTML</label>
<textarea id="html" spellcheck="false" readonly></textarea>
<ul id="notes"></ul>

<script>
  // camelCase CSS name -> kebab-case, e.g. fontWeight -> font-weight
  const kebab = s => s.replace(/[A-Z]/g, c => '-' + c.toLowerCase());

  function convert(src, notes) {
    let out = src;
    const swap = (re, to, note) => { if (re.test(out)) { out = out.replace(re, to); notes.push([note]); } };

    swap(/\bclassName=/g, 'class=', 'className became class');
    swap(/\bhtmlFor=/g, 'for=', 'htmlFor became for');
    swap(/\{\/\*([\s\S]*?)\*\/\}/g, '<!--$1-->', 'JSX comments became HTML comments');

    // style={{ a: 'b', c: 8 }} -> style="a: b; c: 8px"
    swap(/style=\{\{([^}]*)\}\}/g, (m, body) => 'style="' + body.split(',').map(p => {
      const [k, v] = p.split(':').map(x => x && x.trim().replace(/^['"]|['"]$/g, ''));
      if (!k) return '';
      return kebab(k) + ': ' + (/^\d+$/.test(v) && v !== '0' ? v + 'px' : v);
    }).filter(Boolean).join('; ') + '"', 'The style object became a CSS string (numbers got px)');

    // <input ... /> -> <input ...>
    swap(/<(input|img|br|hr|meta|link)\b([^>]*?)\s*\/>/g, '<$1$2>', 'Self-closing void tags lost the slash');

    // onClick={...} cannot live in plain HTML: remove it and say so
    swap(/\s+on[A-Z]\w*=\{[^}]*\}\}?/g, '', 'Event props were removed: add them back with addEventListener in a script');
    return out;
  }

  document.getElementById('go').addEventListener('click', () => {
    const notes = [];
    document.getElementById('html').value = convert(document.getElementById('jsx').value, notes);
    document.getElementById('notes').innerHTML = notes.length
      ? notes.map(([n]) => '<li' + (n.startsWith('Event') ? ' class="warn"' : '') + '>' + n + '</li>').join('')
      : '<li>Nothing to change.</li>';
  });
</script>
</body>
</html>
Paste JSX, press Convert, and get HTML. Event props are removed and listed, because they need a script.

Anything inside curly braces, such as {count} or {items.map(...)}, is JavaScript. In plain HTML, put a placeholder element there and fill it from your script with textContent.

Raw HTML inside a Next.js page

Sometimes the question runs the other way: you have an HTML string and want it inside a Next.js page. Plain React does this with dangerouslySetInnerHTML:

const markup = { __html: '<p>Some <b>trusted</b> HTML</p>' }

export default function Page() {
  return <div dangerouslySetInnerHTML={markup} />
}

The React docs warn that HTML from an untrusted source, such as user input, can create an XSS hole this way. Only pass HTML you wrote or have sanitized.

For whole files such as images and fonts, there is the public folder. A file at public/avatars/me.png is served at /avatars/me.png.

A finished example: several routes in one file

A Next.js site has one folder per route. One HTML file can do the same with the URL hash: each "page" is a <section>, and a short script shows the one that matches #/menu or #/contact.

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>Three routes in one HTML file</title>
<style>
  body { margin: 0; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  nav { display: flex; gap: 4px; padding: 12px 16px; background: #111827; }
  nav a { color: #d1d5db; text-decoration: none; padding: 6px 12px; border-radius: 6px; }
  nav a.active { background: #fff; color: #111827; }
  section { padding: 18px 20px; max-width: 520px; }
  section[hidden] { display: none; }
  h1 { font-size: 22px; margin: 0 0 8px; }
  .price { display: grid; grid-template-columns: 1fr auto; gap: 6px 16px; background: #fff; border-radius: 10px; padding: 12px 14px; }
  form { display: grid; gap: 8px; background: #fff; border-radius: 10px; padding: 14px; }
  input, textarea { font: inherit; padding: 8px; border: 1px solid #cfd4dc; border-radius: 6px; }
  button { font: inherit; padding: 9px 14px; border: 0; border-radius: 8px; background: #111827; color: #fff; cursor: pointer; justify-self: start; }
  #sent { color: #0f5132; white-space: pre-wrap; }
</style>
</head>
<body>
<!-- app/layout.tsx: the part every route shares -->
<nav>
  <a href="#/">Home</a>
  <a href="#/menu">Menu</a>
  <a href="#/contact">Contact</a>
</nav>

<!-- app/page.tsx -->
<section data-route="/">
  <h1>Corner Cafe</h1>
  <p>Open 8 to 6, every day. This whole site is one HTML file.</p>
</section>

<!-- app/menu/page.tsx -->
<section data-route="/menu" hidden>
  <h1>Menu</h1>
  <div class="price"><span>Espresso</span><b>3.00</b><span>Flat white</span><b>4.20</b><span>Croissant</span><b>3.50</b></div>
</section>

<!-- app/contact/page.tsx -->
<section data-route="/contact" hidden>
  <h1>Contact</h1>
  <form id="form">
    <input name="name" placeholder="Your name" required>
    <textarea name="message" rows="3" placeholder="Message" required></textarea>
    <button>Send</button>
    <div id="sent"></div>
  </form>
</section>

<script>
  // Show the section whose data-route matches the hash, e.g. #/menu -> /menu
  function show() {
    const path = location.hash.slice(1) || '/';
    let found = false;
    document.querySelectorAll('section').forEach(s => {
      s.hidden = s.dataset.route !== path;
      if (!s.hidden) found = true;
    });
    if (!found) document.querySelector('[data-route="/"]').hidden = false;
    document.querySelectorAll('nav a').forEach(a => a.classList.toggle('active', a.getAttribute('href') === '#' + path));
  }
  addEventListener('hashchange', show);
  show();

  // No server: handle the form in the page and show what would be sent
  document.getElementById('form').addEventListener('submit', e => {
    e.preventDefault();
    const data = new FormData(e.target);
    document.getElementById('sent').textContent = 'Would send:\n' +
      [...data].map(([k, v]) => k + ': ' + v).join('\n');
  });
</script>
</body>
</html>
Three routes, one shared nav, and a contact form handled in the page. All in one HTML file.
  • Layout: the <nav> sits outside the sections, like the shared part of layout.tsx.
  • Routes: a hashchange listener reads location.hash and un-hides the matching section. The location.hash guide explains the details.
  • Form: with no server, the submit handler calls preventDefault() and shows the form data. To actually receive messages, see forms on static pages.

When it does not work

What you see Cause Fix
Exported index.html has no styles when opened from disk Asset paths start with /_next/ Serve the out folder from a web server
Next.js shows an error after adding output: 'export' A feature that needs a server, such as cookies or redirects Remove it or drop the static export
Pasted JSX shows text, but classes do nothing className is not an HTML attribute Rename to class
Inline styles are ignored style still holds a JSX object Write a CSS string
Buttons do nothing in the HTML file onClick was a React prop Add addEventListener in a script
{count} shows up as literal text Curly braces are JSX, not HTML Use an element and set textContent

For other reasons a page's script will not run, see HTML JavaScript not working.

A deployed Next.js app already has an address. A single HTML file does not, and an attachment may open as plain code, or not at all, on a phone. To host a full build instead, see hosting a built front end.

To send the one-file 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 its scripts run, so the people you send it to can click through the routes and try the form. If you change the code later, the same link shows the new version.

Questions people ask

Can Next.js output plain HTML files?

Yes. Set output: 'export' in next.config.js and run next build. Next.js writes an out folder with one HTML file per route plus the JavaScript and CSS those pages load. The folder can be hosted on any server that serves static files.

Can I turn a Next.js app into one single HTML file?

Not with a Next.js setting. The static export is a folder, and its pages load scripts and styles from the _next/static path. For a small page, rewriting it as plain HTML with a short script, as shown in this guide, is usually quicker.

Where do I put the html and body tags in Next.js?

In the root layout, usually app/layout.tsx. The Next.js docs say the root layout must define the html and body tags. Titles and meta tags go through the Metadata API instead of a hand-written head tag.

How do I render a string of raw HTML in a Next.js page?

React offers the dangerouslySetInnerHTML prop, which takes an object with an __html key. The React docs warn that untrusted HTML passed this way can open an XSS hole, so only use it with trusted, sanitized content.

Why does my exported index.html look broken when I double-click it?

The exported pages ask for their scripts and styles at paths that start with /_next/. Opened straight from disk, those paths do not point into the out folder. Serve the folder from a web server and the paths resolve.

Keep reading