diff --git a/.prettierignore b/.prettierignore
index 787ce4d4..8e955001 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1,4 +1,5 @@
.circleci
+.nuxt
dist
coverage
CHANGELOG.md
diff --git a/packages/vite-plugin/src/lib/build.test.ts b/packages/vite-plugin/src/lib/build.test.ts
new file mode 100644
index 00000000..2eb3d0a1
--- /dev/null
+++ b/packages/vite-plugin/src/lib/build.test.ts
@@ -0,0 +1,64 @@
+import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+
+import { afterEach, beforeEach, describe, expect, test } from 'vitest'
+import { build } from 'vite'
+
+import { createSpaConfigPlugin } from './build.js'
+
+describe('createSpaConfigPlugin', () => {
+ let root: string
+
+ beforeEach(async () => {
+ root = await realpath(await mkdtemp(join(tmpdir(), 'vite-plugin-netlify-spa-config-')))
+ await writeFile(join(root, 'index.html'), '
Hello')
+ })
+
+ afterEach(async () => {
+ await rm(root, { recursive: true, force: true })
+ })
+
+ const configPath = () => join(root, '.netlify/v1/config.json')
+ const readConfig = async (): Promise => JSON.parse(await readFile(configPath(), 'utf8'))
+
+ test('writes build.spa = true for a default (SPA) app', async () => {
+ await build({
+ root,
+ logLevel: 'silent',
+ plugins: [createSpaConfigPlugin()],
+ })
+
+ expect(await readConfig()).toEqual({ build: { spa: true } })
+ })
+
+ test('merges into an existing config.json, preserving other keys', async () => {
+ await mkdir(join(root, '.netlify/v1'), { recursive: true })
+ await writeFile(
+ configPath(),
+ JSON.stringify({ redirects: [{ from: '/a', to: '/b' }], build: { command: 'npm run build' } }),
+ )
+
+ await build({
+ root,
+ logLevel: 'silent',
+ plugins: [createSpaConfigPlugin()],
+ })
+
+ expect(await readConfig()).toEqual({
+ redirects: [{ from: '/a', to: '/b' }],
+ build: { command: 'npm run build', spa: true },
+ })
+ })
+
+ test('does not write config.json when appType is not spa', async () => {
+ await build({
+ root,
+ logLevel: 'silent',
+ appType: 'custom',
+ plugins: [createSpaConfigPlugin()],
+ })
+
+ await expect(readConfig()).rejects.toThrow()
+ })
+})
diff --git a/packages/vite-plugin/src/lib/build.ts b/packages/vite-plugin/src/lib/build.ts
index f3388646..1393c9f6 100644
--- a/packages/vite-plugin/src/lib/build.ts
+++ b/packages/vite-plugin/src/lib/build.ts
@@ -1,4 +1,4 @@
-import { mkdir, writeFile } from 'node:fs/promises'
+import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join, relative, sep } from 'node:path'
import { sep as posixSep } from 'node:path/posix'
@@ -10,6 +10,7 @@ import { version, name } from '../../package.json'
// https://docs.netlify.com/frameworks-api/#netlify-v1-functions
const NETLIFY_FUNCTIONS_DIR = '.netlify/v1/functions'
+const NETLIFY_CONFIG_PATH = '.netlify/v1/config.json'
const NETLIFY_FUNCTION_FILENAME = 'server.mjs'
const NETLIFY_FUNCTION_DEFAULT_NAME = '@netlify/vite-plugin server handler'
@@ -97,3 +98,57 @@ export function createBuildPlugin(options?: { displayName?: string; edgeSSR?: bo
},
}
}
+
+const readNetlifyConfig = async (path: string): Promise> => {
+ try {
+ const jsonFile = await readFile(path, 'utf8')
+ return JSON.parse(jsonFile) as Record
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
+ return {}
+ }
+
+ throw error
+ }
+}
+
+// Marks the site as a single-page app so the Netlify build system serves `index.html` for all paths
+// that don't match a static file, mirroring Vite's own dev server and preview behavior for `appType: 'spa'`.
+// https://docs.netlify.com/frameworks-api/#netlify-v1-configjson
+export function createSpaConfigPlugin(): Plugin {
+ let resolvedConfig: ResolvedConfig
+
+ return {
+ apply: 'build',
+ applyToEnvironment({ name }) {
+ return name === 'client'
+ },
+ configResolved(config) {
+ resolvedConfig = config
+ },
+ name: 'vite-plugin-netlify:spa-config',
+ async writeBundle() {
+ if (resolvedConfig.appType !== 'spa') {
+ return
+ }
+
+ const configPath = join(resolvedConfig.root, NETLIFY_CONFIG_PATH)
+ const config = await readNetlifyConfig(configPath)
+
+ await mkdir(join(resolvedConfig.root, '.netlify/v1'), {
+ recursive: true,
+ })
+
+ await writeFile(
+ configPath,
+ JSON.stringify({
+ ...config,
+ build: {
+ ...(config.build as Record | undefined),
+ spa: true,
+ },
+ }),
+ )
+ },
+ }
+}
diff --git a/packages/vite-plugin/src/main.ts b/packages/vite-plugin/src/main.ts
index 89381c0d..2bd7cd27 100644
--- a/packages/vite-plugin/src/main.ts
+++ b/packages/vite-plugin/src/main.ts
@@ -6,7 +6,7 @@ import dedent from 'dedent'
import type { Plugin } from 'vite'
import { createLoggerFromViteLogger, type Logger } from './lib/logger.js'
-import { createBuildPlugin } from './lib/build.js'
+import { createBuildPlugin, createSpaConfigPlugin } from './lib/build.js'
export interface NetlifyPluginOptions extends Features {
/**
@@ -141,7 +141,7 @@ export default function netlify(options: NetlifyPluginOptions = {}): any {
}
const { enabled, ...buildOptions } = options.build ?? {}
- return [devPlugin, ...(enabled === true ? [createBuildPlugin(buildOptions)] : [])]
+ return [devPlugin, createSpaConfigPlugin(), ...(enabled === true ? [createBuildPlugin(buildOptions)] : [])]
}
const warnOnDuplicatePlugin = (logger: Logger) => {