├── .editorconfig ├── .gitignore ├── .npmrc ├── .vscode └── settings.json ├── README.md ├── eslint.config.mjs ├── package.json ├── playground ├── app.vue ├── assets │ ├── css │ │ └── main.css │ └── img │ │ └── nuxt.png ├── nuxt.config.ts ├── package.json ├── pages │ ├── about.vue │ └── index.vue ├── pnpm-lock.yaml └── tsconfig.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── src ├── inlineHtml.ts ├── module.ts ├── options.ts └── utils.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_size = 2 5 | indent_style = space 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | 4 | # Logs 5 | *.log* 6 | 7 | # Temp directories 8 | .temp 9 | .tmp 10 | .cache 11 | 12 | # Yarn 13 | **/.yarn/cache 14 | **/.yarn/*state* 15 | 16 | # Generated dirs 17 | dist 18 | 19 | # Nuxt 20 | .nuxt 21 | .output 22 | .data 23 | .vercel_build_output 24 | .build-* 25 | .netlify 26 | 27 | # Env 28 | .env 29 | 30 | # Testing 31 | reports 32 | coverage 33 | *.lcov 34 | .nyc_output 35 | 36 | # VSCode 37 | .vscode/* 38 | !.vscode/settings.json 39 | !.vscode/tasks.json 40 | !.vscode/launch.json 41 | !.vscode/extensions.json 42 | !.vscode/*.code-snippets 43 | 44 | # Intellij idea 45 | *.iml 46 | .idea 47 | 48 | # OSX 49 | .DS_Store 50 | .AppleDouble 51 | .LSOverride 52 | .AppleDB 53 | .AppleDesktop 54 | Network Trash Folder 55 | Temporary Items 56 | .apdisk 57 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | strict-peer-dependencies=false 3 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.experimental.useFlatConfig": true 3 | } 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nuxt-single-html 2 | 3 | [![npm version][npm-version-src]][npm-version-href] 4 | [![npm downloads][npm-downloads-src]][npm-downloads-href] 5 | [![License][license-src]][license-href] 6 | [![Nuxt][nuxt-src]][nuxt-href] 7 | 8 | Build a Nuxt app into a single HTML file. 9 | 10 | ## Features 11 | 12 | 13 | - 📄  Generates a fully self-contained HTML file for your Nuxt app. 14 | - 🚀  Works out-of-the-box with zero configuration required for seamless setup. 15 | - 📦  Supports inline JavaScript, CSS and images, etc. 16 | - ⚡  SSG & SPA friendly, works with Nuxt Pages and multiple routes. 17 | - 🧩  Achieve single HTML file generation by adjusting Nuxt config within a Nuxt module, no additional dependencies required. 18 | 19 | ## Why? 20 | 21 | For most websites, bundling your entire web application into a single file is generally NOT RECOMMENDED. However, in certain scenarios, this can be useful, such as: 22 | 23 | - Integrating your web app within desktop or mobile WebView components 24 | - Deploying your web app to environments with strict access requirements 25 | - Embedding your web app in embedded devices 26 | 27 | In these special cases, `nuxt-single-html` allows you to leverage the powerful development experience of Nuxt while easily bundling your web app into a single HTML file, even supporting page pre-rendering. 28 | 29 | This module is specifically designed to work with Nuxt. If you are looking for a similar solution for other frameworks, consider using [vite-plugin-singlefile](https://github.com/richardtallent/vite-plugin-singlefile). 30 | 31 | ## Quickstart 32 | 33 | Install the module to your Nuxt application with one command: 34 | 35 | ```bash 36 | npx nuxi module add nuxt-single-html 37 | ``` 38 | 39 |
40 | Install manually 41 | 42 | ```sh 43 | npm i -D nuxt-single-html 44 | ``` 45 | 46 | ```ts 47 | // nuxt.config.ts 48 | export default defineNuxtConfig({ 49 | modules: ['nuxt-single-html'] 50 | }) 51 | ``` 52 |
53 | 54 | ### Generate single HTML 55 | 56 | After installing, you can now build your Nuxt app into a single HTML now, no configuration required. Since [`nuxi build`](https://nuxt.com/docs/api/commands/build) does not prerender HTML files by default, you need to use [`nuxi generate`](https://nuxt.com/docs/api/commands/generate) to generate the HTML files. 57 | 58 | ```bash 59 | npx nuxi generate 60 | ``` 61 | 62 | The HTML file output path depends on your ([`nitro.output.publicDir`](https://nitro.unjs.io/config#output)) Nuxt configuration. By default, it will be placed in the `.output/public` directory. 63 | 64 | ## Configuration 65 | 66 | You can define these options in your `nuxt.config.js` file under the `singleHtml` key. 67 | 68 | #### Example Configuration: 69 | 70 | ```javascript 71 | export default { 72 | modules: [ 73 | 'nuxt-single-html' 74 | ], 75 | singleHtml: { 76 | enabled: true, 77 | deleteInlinedFiles: true, 78 | output: '[name].html' 79 | } 80 | } 81 | ``` 82 | 83 | #### Options: 84 | 85 | - **`enabled`** (boolean) 86 | - **Description:** Enables or disables the single HTML mode when running the `nuxt generate` command. 87 | - **Default:** `true` 88 | 89 | - **`deleteInlinedFiles`** (boolean) 90 | - **Description:** Specifies whether to delete inlined files after generating the single HTML file. 91 | - **Default:** `true` 92 | 93 | - **`output`** (string) 94 | - **Description:** The output filename for the generated single HTML file. You can use `[name]` to refer to the original HTML file name. 95 | - **Default:** `'[name].html'` 96 | 97 | ## FAQ 98 | 99 | ### How are the assets built? 100 | 101 | All assets (images, fonts, CSS, etc.) inside the [`assets/`](https://nuxt.com/docs/guide/directory-structure/assets) directory will be inlined into the single HTML file. However, assets inside the [`public/`](https://nuxt.com/docs/guide/directory-structure/public) directory will not be inlined. For more details, please refer to the [Nuxt Assets](https://nuxt.com/docs/getting-started/assets) documentation. 102 | 103 | ### Why are multiple HTML files generated? 104 | 105 | If you’re using Nuxt Pages, multiple single HTML files may be generated for each route. Each HTML file contains the full source code for all route pages, meaning they can operate independently and still support SPA behavior. The only difference is that each HTML file is pre-rendered for its corresponding route. 106 | 107 | Simply put, if you only need `index.html` as your entry point, you can deploy just the `index.html` and able to safely ignore the other HTML files. You can also check out config [memory history mode](https://nuxt.com/docs/guide/recipes/custom-routing#custom-history-advanced) to fit your needs. 108 | 109 | ## Contribution 110 | 111 |
112 | Local development 113 | 114 | ```bash 115 | # Install dependencies 116 | pnpm install 117 | 118 | # Generate type stubs 119 | pnpm dev:prepare 120 | 121 | # Develop with the playground 122 | pnpm play 123 | 124 | # Build and preview the playground with single-html module 125 | pnpm play:generate 126 | pnpm play:preview 127 | 128 | # Run ESLint 129 | pnpm lint 130 | pnpm lint:fix 131 | 132 | # Release new version 133 | pnpm release 134 | ``` 135 | 136 |
137 | 138 | 139 | 140 | [npm-version-src]: https://img.shields.io/npm/v/nuxt-single-html/latest.svg?style=flat&colorA=020420&colorB=00DC82 141 | [npm-version-href]: https://npmjs.com/package/nuxt-single-html 142 | 143 | [npm-downloads-src]: https://img.shields.io/npm/dm/nuxt-single-html.svg?style=flat&colorA=020420&colorB=00DC82 144 | [npm-downloads-href]: https://npmjs.com/package/nuxt-single-html 145 | 146 | [license-src]: https://img.shields.io/npm/l/nuxt-single-html.svg?style=flat&colorA=020420&colorB=00DC82 147 | [license-href]: https://npmjs.com/package/nuxt-single-html 148 | 149 | [nuxt-src]: https://img.shields.io/badge/Nuxt-020420?logo=nuxt.js 150 | [nuxt-href]: https://nuxt.com 151 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { createConfigForNuxt } from '@nuxt/eslint-config/flat' 3 | 4 | // Run `npx @eslint/config-inspector` to inspect the resolved config interactively 5 | export default createConfigForNuxt({ 6 | features: { 7 | // Rules for module authors 8 | tooling: true, 9 | // Rules for formatting 10 | stylistic: true, 11 | }, 12 | dirs: { 13 | src: [ 14 | './playground', 15 | ], 16 | }, 17 | }) 18 | .append( 19 | // your custom flat config here... 20 | ) 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nuxt-single-html", 3 | "version": "0.2.0", 4 | "description": "Build a Nuxt app into a single HTML file.", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/serkodev/nuxt-single-html.git" 8 | }, 9 | "license": "MIT", 10 | "type": "module", 11 | "exports": { 12 | ".": { 13 | "types": "./dist/types.d.ts", 14 | "import": "./dist/module.mjs", 15 | "require": "./dist/module.cjs" 16 | } 17 | }, 18 | "main": "./dist/module.cjs", 19 | "types": "./dist/types.d.ts", 20 | "files": [ 21 | "dist" 22 | ], 23 | "scripts": { 24 | "prepack": "nuxt-module-build build", 25 | "play": "nuxi dev playground", 26 | "play:build": "nuxi build playground", 27 | "play:generate": "nuxi generate playground", 28 | "play:preview": "nuxi preview playground", 29 | "dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxi prepare playground", 30 | "prepublishOnly": "pnpm lint && pnpm prepack && bumpp", 31 | "release": "pnpm publish", 32 | "lint": "eslint .", 33 | "lint:fix": "eslint . --fix", 34 | "test": "vitest run", 35 | "test:watch": "vitest watch", 36 | "test:types": "vue-tsc --noEmit && cd playground && vue-tsc --noEmit" 37 | }, 38 | "dependencies": { 39 | "@nuxt/kit": "catalog:nuxt" 40 | }, 41 | "devDependencies": { 42 | "@nuxt/devtools": "2.2.1", 43 | "@nuxt/eslint-config": "^1.2.0", 44 | "@nuxt/module-builder": "^0.8.4", 45 | "@nuxt/schema": "catalog:nuxt", 46 | "@nuxt/test-utils": "^3.17.2", 47 | "@types/node": "^22.13.10", 48 | "bumpp": "^10.0.3", 49 | "eslint": "^9.22.0", 50 | "nuxt": "catalog:nuxt", 51 | "typescript": "~5.6.3", 52 | "vitest": "^3.0.8", 53 | "vue-tsc": "^2.2.8" 54 | }, 55 | "packageManager": "pnpm@10.6.2" 56 | } 57 | -------------------------------------------------------------------------------- /playground/app.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /playground/assets/css/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | color: red; 3 | } 4 | -------------------------------------------------------------------------------- /playground/assets/img/nuxt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/serkodev/nuxt-single-html/b3dc71b33c54ca947f38e053148b85e5b720a1e3/playground/assets/img/nuxt.png -------------------------------------------------------------------------------- /playground/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtConfig({ 2 | modules: ['../src/module'], 3 | devtools: { enabled: true }, 4 | css: [ 5 | '~/assets/css/main.css', 6 | ], 7 | }) 8 | -------------------------------------------------------------------------------- /playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "nuxt-single-html-playground", 4 | "type": "module", 5 | "scripts": { 6 | "dev": "nuxi dev", 7 | "build": "nuxi build", 8 | "generate": "nuxi generate" 9 | }, 10 | "dependencies": { 11 | "nuxt": "catalog:nuxt" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /playground/pages/about.vue: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /playground/pages/index.vue: -------------------------------------------------------------------------------- 1 | 13 | -------------------------------------------------------------------------------- /playground/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.nuxt/tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - playground 3 | 4 | catalog: 5 | nuxt: ^3.16.0 6 | "@nuxt/schema": ^3.16.0 7 | "@nuxt/kit": ^3.16.0 8 | 9 | catalogs: 10 | nuxt: 11 | nuxt: ^3.16.0 12 | "@nuxt/schema": ^3.16.0 13 | "@nuxt/kit": ^3.16.0 14 | -------------------------------------------------------------------------------- /src/inlineHtml.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'node:path' 2 | import { readFileSync, unlinkSync, writeFileSync } from 'node:fs' 3 | import type { SingleHtmlOptions } from './options' 4 | import { findHtmlFiles, removeEmptyDirectories, replaceOutputFileName } from './utils' 5 | 6 | export function processHtmlFiles(baseFolder: string, options: SingleHtmlOptions, baseUrl: string) { 7 | const { deleteInlinedFiles, output } = options 8 | 9 | try { 10 | const toRemoveFiles = new Set() 11 | 12 | for (const entry of findHtmlFiles(baseFolder)) { 13 | const inlinedFiles = inlineFilesInHtml(baseFolder, entry, output, baseUrl) 14 | for (const file of inlinedFiles) 15 | toRemoveFiles.add(file) 16 | } 17 | 18 | if (deleteInlinedFiles) { 19 | console.log('[single-html] Deleting inlined files') 20 | for (const file of toRemoveFiles) { 21 | unlinkSync(file) 22 | } 23 | removeEmptyDirectories(baseFolder) 24 | } 25 | } 26 | catch (error) { 27 | console.error('[single-html] Error processing HTML files:', error) 28 | } 29 | } 30 | 31 | function inlineFilesInHtml(baseFolder: string, htmlPath: string, output: string, baseUrl: string) { 32 | const inlinedFiles: string[] = [] 33 | const baseUrlRemover = new RegExp(`^${baseUrl.replace(/^\//, '')}`) 34 | 35 | function replaceStyleTags(htmlContent: string) { 36 | const regex = /]*rel="stylesheet"[^>]*href="\/([^"]*)"[^>]*>/g 37 | return htmlContent.replace(regex, (match, href) => { 38 | try { 39 | const filePath = join(baseFolder, href.replace(baseUrlRemover, '')) 40 | const contents = readFileSync(filePath, 'utf-8') 41 | inlinedFiles.push(filePath) 42 | return `` 43 | } 44 | catch { 45 | return match // Keep original tag if file not found 46 | } 47 | }) 48 | } 49 | 50 | function replaceScriptTags(htmlContent: string) { 51 | const regex = /]*>[\s\S]*?<\/script>/g 52 | return htmlContent.replace(regex, (match, type, src) => { 53 | try { 54 | const filePath = join(baseFolder, src.replace(baseUrlRemover, '')) 55 | const contents = readFileSync(filePath, 'utf-8') 56 | inlinedFiles.push(filePath) 57 | return `${contents}` 58 | } 59 | catch { 60 | return match // Keep original tag if file not found 61 | } 62 | }) 63 | } 64 | 65 | const filePath = join(baseFolder, htmlPath) 66 | 67 | console.log(`[single-html] Processing single html: ${htmlPath}`) 68 | let html = readFileSync(filePath, 'utf-8') 69 | 70 | html = replaceStyleTags(html) 71 | html = replaceScriptTags(html) 72 | 73 | const outputPath = replaceOutputFileName(htmlPath, output) 74 | writeFileSync(join(baseFolder, outputPath), html, 'utf-8') 75 | 76 | // remove original html file if output is different 77 | if (outputPath !== htmlPath) 78 | unlinkSync(filePath) 79 | 80 | return inlinedFiles 81 | } 82 | -------------------------------------------------------------------------------- /src/module.ts: -------------------------------------------------------------------------------- 1 | import { defineNuxtModule } from '@nuxt/kit' 2 | import { processHtmlFiles } from './inlineHtml' 3 | import type { SingleHtmlOptions } from './options' 4 | 5 | export default defineNuxtModule({ 6 | meta: { 7 | name: 'single-html', 8 | configKey: 'singleHtml', 9 | }, 10 | defaults: { 11 | enabled: true, 12 | deleteInlinedFiles: true, 13 | output: '[name].html', 14 | }, 15 | setup(options, nuxt) { 16 | if (!options.enabled) 17 | return 18 | 19 | // only run when nuxi generate 20 | const isPrerender = nuxt.options.nitro.static 21 | if (!isPrerender && !nuxt.options.dev) { 22 | console.warn('nuxt-single-html module only works with `nuxt generate` or `nuxt build --prerender` commands.') 23 | return 24 | } 25 | 26 | // remove /_nuxt/builds folder 27 | nuxt.options.experimental.appManifest = false 28 | 29 | // disable payload file 30 | nuxt.options.experimental.payloadExtraction = false 31 | 32 | // disable css code splitting 33 | nuxt.options.vite.build ||= {} 34 | nuxt.options.vite.build.cssCodeSplit = false 35 | 36 | // unlimit inline assets (e.g. images) 37 | nuxt.options.vite.build.assetsInlineLimit = Number.MAX_SAFE_INTEGER 38 | 39 | // disable js code splitting 40 | function viteDisableCodeSplitting(config: typeof nuxt.options.vite) { 41 | ;(config.build ||= {}) 42 | ;(config.build.rollupOptions ||= {}).output ||= {} 43 | 44 | if (Array.isArray(config.build.rollupOptions.output)) { 45 | config.build.rollupOptions.output.forEach((output) => { 46 | output.preserveModules = false 47 | output.inlineDynamicImports = true 48 | }) 49 | } 50 | else { 51 | config.build.rollupOptions.output.preserveModules = false 52 | config.build.rollupOptions.output.inlineDynamicImports = true 53 | } 54 | } 55 | 56 | viteDisableCodeSplitting(nuxt.options.vite) 57 | 58 | // override nuxt vite server config (#4) 59 | nuxt.hook('vite:extendConfig', (config) => { 60 | viteDisableCodeSplitting(config) 61 | }) 62 | 63 | // ignore 200.html and 404.html 64 | nuxt.options.nitro.prerender ||= {} 65 | ;(nuxt.options.nitro.prerender.ignore ||= []).push('/200.html', '/404.html') 66 | 67 | // remove prefetch and preload 68 | nuxt.hook('build:manifest', (manifest) => { 69 | for (const key in manifest) { 70 | manifest[key].dynamicImports = [] 71 | manifest[key].prefetch = false 72 | manifest[key].preload = false 73 | } 74 | }) 75 | 76 | // process entry html to replace inline js and css after prerender 77 | nuxt.hook('nitro:build:public-assets', (nitro) => { 78 | const dir = nitro.options.output.publicDir 79 | processHtmlFiles(dir, options, nuxt.options.app.baseURL) 80 | }) 81 | }, 82 | }) 83 | -------------------------------------------------------------------------------- /src/options.ts: -------------------------------------------------------------------------------- 1 | export interface SingleHtmlOptions { 2 | /** 3 | * Enable single HTML mode when nuxt generate. 4 | * @default true 5 | */ 6 | enabled: boolean 7 | 8 | /** 9 | * Specifies whether to delete inlined files after generating the single HTML file. 10 | * @default true 11 | */ 12 | deleteInlinedFiles: boolean 13 | 14 | /** 15 | * The output filename for the generated single HTML file. You can use `[name]` to refer to the original HTML file name. 16 | * e.g. `[name].single.html` will output `index.single.html` for `index.html`. 17 | * @default "[name].html" 18 | */ 19 | output: string 20 | } 21 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { readdirSync, rmdirSync, statSync } from 'node:fs' 2 | import { extname, join } from 'node:path' 3 | 4 | export function findHtmlFiles(targetDir: string): string[] { 5 | const result: string[] = [] 6 | 7 | function traverse(currentDir: string, relativePath: string = '') { 8 | const entries = readdirSync(currentDir, { withFileTypes: true }) 9 | 10 | for (const entry of entries) { 11 | const fullPath = join(currentDir, entry.name) 12 | const entryRelativePath = join(relativePath, entry.name) 13 | 14 | if (entry.isDirectory()) { 15 | traverse(fullPath, entryRelativePath) 16 | } 17 | else if (entry.isFile() && extname(entry.name).toLowerCase() === '.html') { 18 | result.push(entryRelativePath) 19 | } 20 | } 21 | } 22 | 23 | traverse(targetDir) 24 | return result 25 | } 26 | 27 | // recursively scan directories and remove empty ones 28 | export function removeEmptyDirectories(directory: string) { 29 | const files = readdirSync(directory) 30 | for (const file of files) { 31 | const fullPath = join(directory, file) 32 | if (statSync(fullPath).isDirectory()) { 33 | removeEmptyDirectories(fullPath) 34 | const isEmpty = readdirSync(fullPath).length === 0 35 | if (isEmpty) { 36 | rmdirSync(fullPath) 37 | } 38 | } 39 | } 40 | } 41 | 42 | export function replaceOutputFileName(path: string, output: string) { 43 | const pathParts = path.split('/') 44 | const fileName = pathParts[pathParts.length - 1] 45 | const fileNameParts = fileName.split('.') 46 | const name = fileNameParts.slice(0, -1).join('.') 47 | const newFileName = output.replace('[name]', name) 48 | 49 | pathParts[pathParts.length - 1] = newFileName 50 | return pathParts.join('/') 51 | } 52 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.nuxt/tsconfig.json", 3 | "exclude": [ 4 | "dist", 5 | "node_modules", 6 | "playground", 7 | ] 8 | } 9 | --------------------------------------------------------------------------------