The missing link between Vite and Nette. Write {asset 'app.js'} in your Latte template and forget about the rest: in development the browser gets the file straight from the Vite dev server with Hot Module Replacement, in production it gets the hashed, minified, code-split bundle. Same template, no if statements, no manual URLs.
{asset 'app.js'}
{* dev: <script src="http://localhost:5173/@vite/client" type="module"></script>
<script src="http://localhost:5173/app.js" type="module"></script> *}
{* prod: <script src="/assets/app-4f3a2b1c.js" type="module" crossorigin></script> *}- Sensible defaults: the standard Nette project layout is assumed, so there is almost nothing left to configure.
- Automatic dev-mode detection: start
npm run devand PHP picks it up by itself. Stop it, and templates fall back to the production build. Nothing to switch, nothing to remember. - Hot Module Replacement: instant CSS and JavaScript updates without losing application state.
- Full reload for Latte and PHP: templates live outside Vite's module graph, so the plugin watches them for you and reloads the page on change.
- CORS that just works: the PHP app and the dev server always differ in port, often in scheme and host too. The plugin figures out the right allowed origins so the browser never blocks your assets.
- Ready for Docker, reverse proxies, HTTPS and your phone: the awkward setups are covered, usually by a single option.
- Glob entry points:
entry: 'entries/*.ts'instead of a hand-maintained list. - Clean shutdown: the dev-server marker file is removed on Ctrl+C, on kill, and even before a production build, so PHP is never fooled by a stale one.
npm install -D vite @nette/vite-pluginWorks with Vite 6.2+, 7 and 8 on Node.js 22 or newer. On the PHP side you need nette/assets.
1. Put your source files in assets/ and let them compile into www/assets/:
web-project/
├── assets/ ← source files (SCSS, TypeScript, images)
│ ├── public/ ← static files, copied as-is
│ ├── app.js ← entry point
│ └── style.css
└── www/ ← document root
├── assets/ ← compiled output lands here
└── index.php
2. Create vite.config.ts in the project root:
import { defineConfig } from 'vite';
import nette from '@nette/vite-plugin';
export default defineConfig({
plugins: [
nette({
entry: 'app.js',
}),
],
});3. Tell Nette Assets to use the Vite mapper in common.neon:
assets:
mapping:
default:
type: vite
path: assets4. Add the scripts to package.json:
{
"scripts": {
"dev": "vite",
"build": "vite build"
}
}Now npm run dev gives you HMR and npm run build gives you an optimized production bundle. In your templates just write {asset 'app.js'} and Nette Assets generates every tag needed: JavaScript, extracted CSS, preloads and all.
You can override any of these in your own Vite config; the plugin only fills in what you did not set.
| Option | Value | Why |
|---|---|---|
root |
assets |
source files live outside the document root |
build.outDir |
www/assets |
compiled files must be publicly reachable |
build.assetsDir |
'' |
output lands directly in outDir, no static/ subfolder |
build.manifest |
true |
Nette Assets maps hashed filenames through it |
base |
'' |
assets are served straight from the document root |
server.cors |
app origin allowed | the browser would otherwise refuse dev assets |
server.allowedHosts |
the configured host, when it isn't localhost |
works behind a proxy out of the box |
server.origin |
the dev server URL | Vite rewrites asset URLs to itself, not to the backend |
The default outDir requires an existing www/ directory. If it is missing, the plugin stops with a clear error instead of quietly building into the wrong place.
All options are optional.
| Option | Type | Default | Description |
|---|---|---|---|
entry |
string | string[] |
– | entry point(s), relative to root, glob patterns allowed |
refresh |
string | string[] |
– | globs that trigger a full page reload (Latte, PHP) |
host |
string |
– | host advertised to the browser, or 'network' for the LAN IP |
appUrl |
string |
– | URL of your PHP application, used for CORS |
infoFile |
string |
.vite/nette.json |
dev-server marker file, relative to outDir |
An entry point is where your application starts; Vite follows its imports and bundles everything it finds.
nette({
entry: [
'app.js', // public pages
'admin.js', // admin panel
],
})Insert them in the templates that need them: {asset 'app.js'} in the layout, {asset 'admin.js'} in the admin one.
When every entry point lives in its own directory, use a glob instead of a hand-written list. It is expanded relative to root:
nette({
entry: 'entries/*.ts', // all .ts files in assets/entries/
})Unlike raw rollupOptions.input, paths in entry are resolved against root, so a plain 'app.js' means assets/app.js, which is what you would expect.
Vite's HMR only sees files in its own module graph. Latte templates and PHP files are not in it, so editing them does nothing in the browser until you reload by hand. Give the refresh option a glob and the plugin reloads the page for you:
nette({
entry: 'app.js',
refresh: ['app/**/*.latte', 'app/**/*.php'],
})Patterns are matched against the whole project, not just root, and additions, changes and deletions all count.
While the dev server runs, the plugin writes a small JSON file www/assets/.vite/nette.json containing its URL. Nette Assets reads it and switches to the dev server whenever both the file exists and the application is in debug mode. No environment variables, no flags in your config.
The file is deleted when the server stops (Ctrl+C, SIGTERM, on Windows also Ctrl+Break), and a leftover one from a crashed process is removed before every production build, so a build is never shadowed by a stale "dev server is running" marker. It also carries the pid and a timestamp, so a consumer can recognize a file left behind by a crashed dev server.
If the location clashes with something in your project, rename it with the infoFile option.
If your PHP application is served from https://myapp.local while Vite runs on localhost:5173, the browser treats them as different origins and blocks the requests. Tell the plugin where the application lives and it allows that origin over http or https on any port:
nette({
entry: 'app.js',
appUrl: 'https://myapp.local',
})If you prefer to run Vite on the very same hostname as your app, set host instead. CORS for the differing port is then configured automatically, and the host is whitelisted in allowedHosts too:
nette({ host: 'myapp.local' })Publish the Vite port from the container and bind the dev server to all interfaces:
export default defineConfig({
plugins: [nette({ entry: 'app.js' })],
server: {
host: '0.0.0.0',
port: 5173,
strictPort: true,
watch: {
usePolling: true, // if changes on mounted volumes go unnoticed
},
},
});0.0.0.0 is not an address a browser can use, so the plugin rewrites it to localhost in the published URL and assets keep loading. Opening the app on a custom domain? Add nette({ host: 'myapp.local' }) and the host is used for the URL, the CORS origins and allowedHosts at once.
For a reverse proxy where the public host, port and protocol all differ from the internal socket, set Vite's server.origin to the full public URL. The plugin respects it and publishes it as-is:
server: {
origin: 'https://myapp.local:8443',
}Set host to the 'network' sentinel. The plugin binds Vite to all interfaces and advertises your machine's LAN IP instead of localhost, so assets and HMR work when you open the app from a phone or tablet on the same network:
nette({
entry: 'app.js',
host: 'network',
})If no external address is found, it falls back to localhost.
Generate certificates automatically with vite-plugin-mkcert. The plugin notices server.https and publishes an https:// URL:
import mkcert from 'vite-plugin-mkcert';
export default defineConfig({
plugins: [
mkcert(),
nette({ entry: 'app.js' }),
],
});npm run buildVite minifies JavaScript and CSS, splits the code into optimal chunks, hashes filenames for cache busting and writes the manifest that Nette Assets reads:
www/assets/
├── app-4f3a2b1c.js # your JavaScript, minified
├── app-7d8e9f2a.css # extracted CSS
├── vendor-8c4b5e6d.js # shared dependencies
└── .vite/
└── manifest.json # mapping for Nette Assets
Files placed in assets/public/ are copied over untouched and remain reachable by {asset 'favicon.ico'} through a filesystem fallback.
The complete guide (project structure, {asset} and {preload} in templates, the public folder, dynamic imports, TypeScript) lives at doc.nette.org/en/assets/vite.
Do you like the Nette Vite plugin? Are you looking forward to the new features?
Thank you!