Building FiveM NUI with Svelte or Vue and Vite
Keep the source in web/ and build into html/. In vite.config set base: './' so assets resolve under https://cfx-nui-<resource>/, set build.outDir to ../html and use a conservative build.target. Point ui_page at html/index.html and list html/** in files. Wrap the message listener in a Svelte helper or a Vue composable, wrap callbacks in a fetchNui helper with mocks, keep html and body transparent, and test the production build in game before shipping.
React gets most of the attention in FiveM tutorials, but Svelte and Vue are excellent choices for NUI. Svelte compiles to small, fast vanilla JavaScript with almost no runtime, which suits HUDs that update several times a second. Vue has a gentle learning curve and a mature ecosystem. Both build with Vite, and the FiveM-specific parts are identical: relative asset paths, a transparent page, a message listener, a callback helper and a way to develop without the game.

#Svelte, Vue or React for NUI?
| Svelte | Vue | React | |
|---|---|---|---|
| Runtime shipped | Very small; most work happens at compile time | A runtime library | A runtime library |
| Updates | Compiled reactive assignments | Reactive proxies | Re-render and diff |
| Good fit | HUDs, speedometers, small overlays | Phones, MDTs, admin panels, forms | Large apps, teams used to React |
| Learning curve | Low | Low | Medium |
All three are fine in FiveM’s browser when built to static files. Pick what you or your team write fastest. If you already use React, the equivalent setup is in the React NUI boilerplate.
#Scaffolding the project
# Svelte + TypeScript
npm create vite@latest web -- --template svelte-ts
# or Vue + TypeScript
npm create vite@latest web -- --template vue-ts
cd web
npm installmy_resource/
fxmanifest.lua
client.lua
web/ source (never shipped)
src/
index.html
vite.config.ts
package.json
html/ build output (shipped)
index.html
assets/Keep node_modules out of the resource you deploy. Servers should only receive html/; shipping web/node_modules makes the resource enormous and slows the server’s file scanning.
#vite.config for FiveM
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [svelte()],
base: './',
build: {
outDir: '../html',
emptyOutDir: true,
target: 'es2020',
assetsInlineLimit: 0,
},
});import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
base: './',
build: {
outDir: '../html',
emptyOutDir: true,
target: 'es2020',
},
});base: './'makes the builtindex.htmlload./assets/…. Without it Vite writes/assets/…, which points at the root of the cfx-nui host and gives you a blank page. The full explanation is in NUI asset paths.outDir: '../html'puts the build next to the manifest;emptyOutDir: trueis required because the folder is outsideweb/.- A conservative
targetavoids syntax that an older embedded Chromium might not parse. assetsInlineLimit: 0(optional) keeps images as files instead of base64 strings inside the bundle, which keeps the JavaScript small.
#The fxmanifest and global CSS
fx_version 'cerulean'
game 'gta5'
ui_page 'html/index.html'
files {
'html/index.html',
'html/assets/*',
}
client_script 'client.lua'html,
body {
margin: 0;
background: transparent;
overflow: hidden;
user-select: none;
}
.hidden {
display: none !important;
}Remove the template’s demo styles (the default body background and centred layout); a dark background from the template covers the whole game. user-select: none stops drag-selecting UI text by accident, and you can re-enable it on inputs.
#Svelte: messages and callbacks
export const inGame = typeof (window as any).GetParentResourceName === 'function';
const resource = inGame ? (window as any).GetParentResourceName() : 'dev';
export async function fetchNui<T = unknown>(name: string, data: unknown = {}, mock?: T): Promise<T> {
if (!inGame) return mock as T;
const res = await fetch(`https://${resource}/${name}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
body: JSON.stringify(data ?? {}),
});
return res.json();
}
export function onNui<T>(action: string, handler: (data: T) => void): () => void {
const listener = (e: MessageEvent) => {
if (e.data?.action === action) handler(e.data as T);
};
window.addEventListener('message', listener);
return () => window.removeEventListener('message', listener);
}
export function mockNui(action: string, data: object, delay = 500) {
if (inGame) return;
setTimeout(() => window.dispatchEvent(new MessageEvent('message', { data: { action, ...data } })), delay);
}<script lang="ts">
import { onMount } from 'svelte';
import { fetchNui, onNui, mockNui } from './lib/nui';
let visible = $state(false);
let cash = $state(0);
onMount(() => {
const offVisible = onNui<{ value: boolean }>('visible', (d) => (visible = d.value));
const offCash = onNui<{ cash: number }>('cash', (d) => (cash = d.cash));
mockNui('visible', { value: true });
mockNui('cash', { cash: 12500 });
fetchNui('ready');
return () => { offVisible(); offCash(); };
});
function close() {
visible = false;
fetchNui('close');
}
</script>
<svelte:window onkeyup={(e) => e.key === 'Escape' && visible && close()} />
{#if visible}
<div class="panel">
<p>Cash: ${cash.toLocaleString()}</p>
<button onclick={close}>Close</button>
</div>
{/if}The ready call at mount time implements the handshake described in SendNUIMessage patterns, so Lua knows when it is safe to send initial state. In Svelte 4 projects, replace $state with plain let variables and onclick with on:click.
#Vue: a composable for NUI events
import { onMounted, onUnmounted } from 'vue';
import { onNui } from '../lib/nui';
export function useNuiEvent<T>(action: string, handler: (data: T) => void) {
let off: (() => void) | undefined;
onMounted(() => { off = onNui<T>(action, handler); });
onUnmounted(() => off?.());
}<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useNuiEvent } from './composables/useNuiEvent';
import { fetchNui, mockNui } from './lib/nui';
const visible = ref(false);
const cash = ref(0);
useNuiEvent<{ value: boolean }>('visible', (d) => (visible.value = d.value));
useNuiEvent<{ cash: number }>('cash', (d) => (cash.value = d.cash));
onMounted(() => {
mockNui('visible', { value: true });
mockNui('cash', { cash: 12500 });
fetchNui('ready');
});
function close() {
visible.value = false;
fetchNui('close');
}
</script>
<template>
<div v-show="visible" class="panel">
<p>Cash: ${{ cash.toLocaleString() }}</p>
<button @click="close">Close</button>
</div>
</template>v-show toggles display: none, which keeps the component alive between openings (fast re-open, preserved scroll position). Use v-if instead for heavy panels you want fully removed while closed. Global state shared by several components fits naturally in a Pinia store or a single reactive object exported from a module.
#Development loop and shipping checks
- Run
npm run devinweb/and build the UI in Chrome with the mock messages. - For in-game hot reload, temporarily set
ui_page 'http://localhost:5173'and restart the resource; switch back before release. - Run
npm run build, restart the resource, and test the real build in game. - Open the NUI devtools and check the Network tab for 404s and the Console for errors.
- Check the bundle size in the build output; a HUD should be tens of kilobytes, not megabytes.
The same setup works for loading screens: the only differences are loadscreen 'html/index.html' instead of ui_page and listening to loading events instead of your own messages, as shown in the React and Vite loading screen guide.
#State for larger interfaces
A small overlay can keep its state inside App. A phone or an MDT with several screens needs shared state that any component can read and that the message listener can update from one place.
import { onNui } from './nui';
export const ui = $state({
visible: false,
page: 'home' as 'home' | 'contacts' | 'messages',
player: { name: '', job: '' },
});
onNui<{ value: boolean }>('visible', (d) => (ui.visible = d.value));
onNui<{ name: string; job: string }>('player', (d) => (ui.player = { name: d.name, job: d.job }));Any component imports ui and reads ui.player.name; updates from Lua flow straight into every place that shows them. In Vue the equivalent is a Pinia store whose actions are called from useNuiEvent handlers. Keep message handling in the store, not scattered across components, so there is exactly one place to look when a value is wrong.
| Mistake | Symptom | Fix |
|---|---|---|
| Template CSS left in | Dark or white rectangle over the game | Delete the template styles; transparent html, body |
base not set | Blank page, 404s for /assets/… | base: './' |
| Listener added in a component that remounts | Messages handled twice | Remove listeners on unmount or register once in a store |
v-if on a frequently toggled HUD | Stutter when showing | v-show or CSS classes |
Dev server URL left in ui_page | Blank UI for every player | Point back to html/index.html before release |
Questions
Can I use Svelte for FiveM NUI?
base: './', point ui_page at the built index.html and list the output in files.Why is my Vue or Svelte NUI a blank page in game?
base: './' in vite.config and rebuild, then check the devtools Network tab for 404s.Should I ship node_modules with my resource?
node_modules out of the server.Is Svelte faster than React for a FiveM HUD?
How do I test my NUI without starting FiveM?
message events, and return mock data from fetchNui when GetParentResourceName is missing.Ready to pick a map?
Twelve themes on three base map styles, $8 each, instant download.
Relevant to what you just read
The tool for this
A drag-and-drop loading screen, hosted for you From Free, built in the browser, exported as a drop-in resource.
Look it up while you work
The reference tables this guide leans on, free and without an account.
- The map modding glossary38 terms — file formats, radar parts, framework words — each with a one-line answer first.