HTML-to-Next.js converters exist, but they translate markup into React components. They can't know your URL rules, your redirects, where your forms post or how your scripts behave once pages stop doing full reloads. For a real site, the dependable route is a manual App Router migration: one app/<route>/page.tsx per HTML file, <head> tags moved into the Metadata API, a root layout for the shared header and footer, scripts loaded through next/script, and a crawl of the old and new sites to prove nothing changed.
What an HTML to Next.js converter can and can't do
There are three kinds of tool:
- HTML-to-JSX converters. The React docs recommend a converter such as transform.tools for the tedious part:
classbecomesclassName,forbecomeshtmlFor, every tag gets closed, andstyle="background-color: black"becomesstyle={{ backgroundColor: 'black' }}. - Project converters. HTMstudio, which labels itself beta, says it turns a saved HTML page into a "production-ready Next.js 14 App Router project" and can batch a folder of files. Next.js is now at 16.4 (October 6, 2026), so check the version a generated project pins.
- AI coding agents. They can make the edits across many files.
create-next-appcan add anAGENTS.mdthat points agents at the docs bundled with your installed version (installation docs).
Whichever you use, some decisions aren't in the HTML:
| Part of the job | Can a tool work it out from the HTML? |
|---|---|
JSX syntax (className, closed tags, style objects) |
Yes |
| One route per page, shared header and footer pulled out | Partly; review the result |
Trailing slash and .html URL policy |
No |
| Redirects for URLs that change | No; needs your URL history |
| Scripts that assume a full page load | No; needs browser testing |
| Where forms submit | No; needs an endpoint |
When Next.js is worth it, and when to keep HTML
Keep plain HTML for a handful of pages that rarely change: no build step, no framework to patch. Next.js ships security fixes often: a September 30, 2026 release, then an October 14, 2026 update that fixed two critical remote-code-execution bugs. Those live in the Next.js server, which a static export served as files doesn't run. Every Next.js page also boots React in the browser to reconcile and hydrate it.
Move to Next.js when you're pasting the same header into dozens of files, want CMS or Markdown content rendered at build time, need React components, need server code (form handlers, auth, an API), or your team already works in React.
Step-by-step migration to the App Router
1. List every URL first
Pull the old sitemap.xml, or crawl the site (Screaming Frog's SEO Spider crawls up to 500 URLs without a paid licence). Record the exact form of each URL: /about.html, /about/ or /about. That list decides your routes, your trailing-slash setting, your redirects and the final check. The redirect map guide covers the spreadsheet.
2. Create the project
npx create-next-app@latest my-site
Choose "customize settings": yes to TypeScript and the App Router, no to Tailwind if you're keeping your own CSS, and no to Cache Components. It has been the default for new apps since 16.4, but a converted brochure site doesn't need it. Node.js 20.9 or later is required.
3. Move assets into public/
Copy images, fonts and downloads into public/; public/img/logo.png is served at /img/logo.png (docs). Rewrite relative paths like ../img/logo.png as root-relative /img/logo.png. In markup, relative paths break when a URL gains or loses a trailing slash; in CSS, Next.js resolves them at build time from the stylesheet's location, so they stop resolving once the images live in public/. Move favicon.ico into app/; Next.js only reads it from the top level of app/.
4. Turn each HTML file into a route
In the App Router, folders define URL segments and a page.tsx makes the folder a page:
| Old file | New file | URL |
|---|---|---|
index.html |
app/page.tsx |
/ |
about.html |
app/about/page.tsx |
/about |
blog/first-post.html |
app/blog/first-post/page.tsx |
/blog/first-post |
Run each page's <body> (minus header and footer) through the JSX converter and paste it into the component:
// app/about/page.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'About us',
description: 'Who we are and how we work.',
alternates: { canonical: '/about' },
}
export default function AboutPage() {
return <main className="page-about">{/* converted markup */}</main>
}
Inline handlers like onclick="toggle()" won't carry over. React's onClick takes a function, and event handlers only run in Client Components (files that start with 'use client').
5. Keep URLs identical: trailing slashes and .html
By default Next.js redirects /about/ to /about. If your old URLs ended in a slash, set trailingSlash: true and the redirect runs the other way.
For .html URLs, the simpler choice is clean URLs plus a permanent redirect from each old address (step 13). Google treats both 301 and 308 as permanent redirects that signal the target should be canonical. To keep .html exactly on a Node.js deployment, add a rewrite from /about.html to /about (rewrites also apply to <Link> navigation) and set the canonical to the .html URL. The docs warn that rewrites plus usePathname() on prerendered pages can cause hydration mismatch errors, and a static export has no rewrites.
6. Put the shared header and footer in the root layout
The root layout is required and holds <html> and <body>, so it replaces the boilerplate every HTML file repeated:
// app/layout.tsx
import type { Metadata } from 'next'
import './globals.css'
import { SiteHeader } from './site-header'
import { SiteFooter } from './site-footer'
export const metadata: Metadata = {
metadataBase: new URL('https://www.example.com'),
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<SiteHeader />
{children}
<SiteFooter />
</body>
</html>
)
}
A hard-coded class="active" nav link becomes logic: read the path with usePathname() in a Client Component.
7. Move <head> into the Metadata API
The Metadata API has a few rules that catch migrations:
- Set
metadataBaseonce; relative URL fields without it are a build error. - Copy each old
<title>and description verbatim. Usetitle.templateonly if every old title followed one pattern. - Delete charset and viewport tags; Next.js adds both.
- Metadata merges shallowly. A page that sets
openGraphreplaces the layout's wholeopenGraphobject, image included. - It doesn't cover everything: import stylesheets, load scripts with
next/script, and render<noscript>or<base>in the layout. - A canonical of
/renders ashttps://www.example.comwithout the slash. That's the same URL.
8. Use next/link where it matters
<Link> prefetches routes as links enter the viewport and swaps only the page, keeping shared layouts, so use it for internal navigation. Keep plain <a> for external links, files in public/ and pages whose scripts still expect a full load. On link-heavy pages, prefetch={false} turns off viewport prefetching.
9. Move scripts to next/script
Pick a strategy per script:
- Analytics and tag managers:
afterInteractive, the default, in the root layout. - Chat widgets and social embeds:
lazyOnload. - Consent managers and bot detection:
beforeInteractive, which must sit in the root layout. - Inline snippets need an
id.
Your own DOM scripts are the trap. afterInteractive scripts are injected client-side, after some or all of hydration, and for injected scripts, MDN notes, DOMContentLoaded has already fired. A layout script also loads only once, so a script that looks up elements when it runs won't wire up elements on pages reached by client-side navigation. Rewrite small behaviors as Client Components:
'use client'
import { useState } from 'react'
export function MobileMenu() {
const [open, setOpen] = useState(false)
return (
<>
<button aria-expanded={open} onClick={() => setOpen(!open)}>Menu</button>
<nav className={open ? 'nav is-open' : 'nav'}>{/* links */}</nav>
</>
)
}
For an embed that must re-initialize on every visit, such as a map, use onReady in a Client Component; it runs after the first load and on every later mount.
10. Start with one global stylesheet
Paste your CSS into app/globals.css and import it once in the root layout. Don't import page-specific global CSS inside pages: Next.js "currently does not remove stylesheets as you navigate between routes which can lead to conflicts" (CSS docs). Move page-specific rules into CSS Modules later, and check CSS order in a production build, because it can differ in next dev.
11. Images: next/image or plain <img>
next/image serves sized WebP images, lazy-loads them and prevents layout shift. It needs width and height unless the image is statically imported or uses fill, and its src becomes /_next/image?url=…. The default optimizer needs a server, so a static export needs images: { unoptimized: true } or a custom loader. Plain <img> still works. Convert large above-the-fold images first.
12. Give forms somewhere to post
A form posting to a third-party service keeps working. Netlify Forms is the exception: on Netlify's Next.js runtime, form attributes in your pages "have no effect", so you declare the form in a static HTML file in public/ and post to that. To handle submissions yourself, add a Route Handler and point <form action="/api/contact" method="post"> at it:
// app/api/contact/route.ts
export async function POST(request: Request) {
const form = await request.formData()
const email = String(form.get('email') ?? '')
// validate, then send with your email provider's API
return Response.redirect(new URL('/thanks', request.url), 303)
}
This needs a server; a static export can't run it.
13. Redirects, and output: 'export' for static hosting
On a Node.js deployment, redirects live in next.config.ts. permanent: true sends a 308, redirects run before pages and public/ files, and query strings carry over:
// next.config.ts
import type { NextConfig } from 'next'
const moved = ['about', 'services', 'contact'] // from your URL list
const nextConfig: NextConfig = {
async redirects() {
return moved.map((page) => ({
source: `/${page}.html`,
destination: `/${page}`,
permanent: true,
}))
},
}
export default nextConfig
If you set trailingSlash: true, make the destination `/${page}/` so each old URL takes one hop.
For static hosting, set output: 'export'. next build writes one HTML file per route to out/: about.html, or about/index.html with trailingSlash: true. A static export drops redirects, rewrites, headers, Proxy (called Middleware before Next.js 16), Server Actions and the default image optimizer. Redirects then belong to your host: on Cloudflare Pages, a _redirects file in public/ lands in out/; write 301 on each line, because Pages defaults to 302. Pages also redirects /contact.html to /contact by itself.
14. Verify nothing was lost
Run npm run build, then npm run lint; since Next.js 16, the build no longer lints. The ESLint plugin flags leftover <img> tags, synchronous scripts and stylesheet <link> tags. Deploy a preview, then compare it with the live site. This Node.js script reads the old sitemap and prints every difference in status, redirect target, title, canonical and description:
// node compare.mjs https://www.example.com https://preview.example.pages.dev
const [oldSite, newSite] = process.argv.slice(2)
const sitemap = await (await fetch(`${oldSite}/sitemap.xml`)).text()
const paths = [...sitemap.matchAll(/<loc>\s*(.*?)\s*<\/loc>/g)].map((m) => new URL(m[1]).pathname)
const tag = (html, re) => html.match(re)?.[0] ?? ''
const attr = (t, name) => t.match(new RegExp(`${name}="([^"]*)"`, 'i'))?.[1] ?? ''
async function check(site, path) {
const res = await fetch(site + path, { redirect: 'manual' })
const html = res.status === 200 ? await res.text() : ''
return {
status: res.status,
location: res.headers.get('location') ?? '',
title: html.match(/<title[^>]*>([^<]*)/i)?.[1].trim() ?? '',
canonical: attr(tag(html, /<link[^>]*rel="canonical"[^>]*>/i), 'href'),
description: attr(tag(html, /<meta[^>]*name="description"[^>]*>/i), 'content'),
}
}
for (const path of paths) {
const [a, b] = await Promise.all([check(oldSite, path), check(newSite, path)])
const keys = a.status === b.status ? Object.keys(a) : ['status', 'location']
for (const k of keys) if (a[k] !== b[k]) console.log(`${path} ${k}: "${a[k]}" -> "${b[k]}"`)
}
Expect a little noise, such as & versus & in a title. Treat everything else as a bug. Then confirm a made-up URL returns a 404 status, not a 200, and work through the launch checklist.
Where FNJ fits
If the site was built in Framer, FNJ converts the published site from its URL into a prerendered Next.js App Router project, split into components named after the Framer sections, with Framer's runtime kept so animations behave as designed (Framer to Next.js). For Webflow, Wix, Squarespace and eight other builders, FNJ captures the public pages as static HTML (HTML converters). Next.js output for those is in development, so today you'd take that HTML through the steps above. A capture doesn't include forms, stores, bookings or logins; those need replacement services.
Frequently asked questions
Can I convert an HTML website to Next.js automatically?
Partly. Converters handle the HTML-to-JSX syntax, and some generate a whole project from saved pages. None can know your redirects, form endpoints or how your scripts behave on client-side navigation, so review the output and test every page.
Do I have to rewrite my CSS for Next.js?
No. Import your existing stylesheet once in app/layout.tsx and the site should look the same. Move page-specific rules into CSS Modules later, because global stylesheets imported inside pages aren't removed when users move between routes.
Will moving from HTML to Next.js hurt my SEO?
Not if every URL keeps its title, canonical and status code, or permanently redirects to its new address. Compare a crawl of the old site with the new one before you switch DNS, and fix every difference.
Can a Next.js site be hosted as static files?
Yes. With output: 'export', next build writes HTML, CSS and JavaScript to an out folder that any static host can serve. You give up redirects and rewrites in next.config, Server Actions, Route Handlers that read the request, and the default image optimizer.
How do I keep .html URLs in Next.js?
On a Node.js server, add a rewrite from /page.html to /page and point the canonical at the .html URL. A static export already names its files page.html, but some hosts, Cloudflare Pages among them, redirect those to extension-less URLs. Clean URLs with permanent redirects are usually simpler.
Starting a new site instead? FNJ's AI website builder designs one from a description and a site you like, then gives you the code to host anywhere.