# Building FiveM NUI with Svelte or Vue and Vite

> Set up a Svelte or Vue NUI for FiveM with Vite: folder layout, vite.config for cfx-nui paths, fxmanifest, message helpers, mocks and build checks.

Source: https://fam-tools.com/guides/fivem-nui-svelte-vue-vite · FAM Tools · published 2026-09-19 · updated 2026-09-19 · about 11 min

**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.

## Key points

- Source in `web/`, build output in `html/`; only the build ships.
- `base: './'` is the setting that prevents blank pages.
- Keep the page background transparent and hide the UI with `display: none`.
- One message helper per framework: a Svelte function, a Vue composable.
- Mock messages and callbacks in development so you can work in Chrome.
- Svelte suits high-frequency HUDs; Vue suits larger, form-heavy UIs.

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](https://fam-tools.com/guides/fivem-nui-react-boilerplate-structure).

## Scaffolding the project

*inside your resource folder*

```bash
# 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*

```text
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)*

```ts
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)*

```ts
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](https://fam-tools.com/guides/fivem-nui-assets-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*

```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*

```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)*

```ts
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)*

```svelte
<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](https://fam-tools.com/guides/fivem-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*

```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*

```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](https://fam-tools.com/guides/fivem-nui-devtools-debugging) 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.

> **Styling libraries and blur** — Component libraries often use `backdrop-filter` for modals and cards. It does not render reliably in FiveM’s browser, so override those styles with solid semi-transparent colours. See [CEF limitations](https://fam-tools.com/guides/fivem-nui-cef-limitations).

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](https://fam-tools.com/guides/fivem-loading-screen-react-vite).

## 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)*

```ts
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?

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.

## Related

- https://fam-tools.com/guides/fivem-nui-react-boilerplate-structure
- https://fam-tools.com/guides/fivem-nui-assets-paths
- https://fam-tools.com/guides/fivem-sendnuimessage-patterns
- https://fam-tools.com/guides/fivem-loading-screen-react-vite
