NUI and CEFintermediate11 min readupdated 19 Sept 2026

Building FiveM NUI with Svelte or Vue and Vite

The short answer

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.

Building FiveM NUI with Svelte or Vue and Vite — illustrated with a FiveM-compatible minimap render

#Svelte, Vue or React for NUI?

SvelteVueReact
Runtime shippedVery small; most work happens at compile timeA runtime libraryA runtime library
UpdatesCompiled reactive assignmentsReactive proxiesRe-render and diff
Good fitHUDs, speedometers, small overlaysPhones, MDTs, admin panels, formsLarge apps, teams used to React
Learning curveLowLowMedium

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

inside your resource folder
# Svelte + TypeScript
npm create vite@latest web -- --template svelte-ts

# or Vue + TypeScript
npm create vite@latest web -- --template vue-ts

cd web
npm install
resource layout
my_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

web/vite.config.ts (Svelte)
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,
  },
});
web/vite.config.ts (Vue)
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 built index.html load ./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: true is required because the folder is outside web/.
  • A conservative target avoids 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

fxmanifest.lua
fx_version 'cerulean'
game 'gta5'

ui_page 'html/index.html'

files {
    'html/index.html',
    'html/assets/*',
}

client_script 'client.lua'
web/src/app.css
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

web/src/lib/nui.ts (shared by both frameworks)
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);
}
web/src/App.svelte (Svelte 5)
<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

web/src/composables/useNuiEvent.ts
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?.());
}
web/src/App.vue
<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

  1. Run npm run dev in web/ and build the UI in Chrome with the mock messages.
  2. For in-game hot reload, temporarily set ui_page 'http://localhost:5173' and restart the resource; switch back before release.
  3. Run npm run build, restart the resource, and test the real build in game.
  4. Open the NUI devtools and check the Network tab for 404s and the Console for errors.
  5. 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.

web/src/lib/store.svelte.ts (Svelte 5)
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.

MistakeSymptomFix
Template CSS left inDark or white rectangle over the gameDelete the template styles; transparent html, body
base not setBlank page, 404s for /assets/…base: './'
Listener added in a component that remountsMessages handled twiceRemove listeners on unmount or register once in a store
v-if on a frequently toggled HUDStutter when showingv-show or CSS classes
Dev server URL left in ui_pageBlank UI for every playerPoint back to html/index.html before release
Share this guideXRedditFacebook

Questions

Can I use Svelte for FiveM NUI?
Yes. Build it with Vite to static files, set 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?
Usually absolute asset paths. Set base: './' in vite.config and rebuild, then check the devtools Network tab for 404s.
Should I ship node_modules with my resource?
No. Ship only the build output folder; keep the source and node_modules out of the server.
Is Svelte faster than React for a FiveM HUD?
Svelte ships less runtime and updates without a virtual DOM, which suits frequent small updates. A well-written React HUD is also fine; message frequency matters more than the framework.
How do I test my NUI without starting FiveM?
Run the Vite dev server and dispatch mock 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.