Back to Blog
Development 15 min read April 17, 2026

How to Create NUI Interfaces for FiveM with Vue 3 & Vite (2026)

Build modern FiveM NUI interfaces using Vue 3 and Vite. Covers project setup, Lua-to-NUI communication, NUI callbacks, responsive design, and production build optimization.

fivem nui vue 3 fivem ui nui development fivem frontend vite fivem

Why Vue 3 for FiveM NUI

FiveM NUI (Network User Interface) renders web content inside the game using Chromium Embedded Framework (CEF). Any HTML, CSS, and JavaScript that runs in a browser works inside FiveM. This means you can use modern frameworks like Vue 3, React, or Svelte for your in-game interfaces.

Vue 3 with Vite is the best choice for FiveM because:

  • Fast hot reload — Vite's HMR lets you see changes instantly during development
  • Small bundle size — Vue 3 tree-shakes to ~16KB gzipped, critical for NUI performance
  • Composition API — Clean, reusable logic without class overhead
  • TypeScript support — Type safety for complex UIs like inventories or job panels

Step 1: Project Structure

A FiveM resource with a Vue 3 NUI follows this structure:

bash
my-resource/

├── fxmanifest.lua

├── client/

│ └── main.lua

├── server/

│ └── main.lua

└── web/ # Vue 3 project

├── index.html

├── package.json

├── vite.config.ts

├── tsconfig.json

└── src/

├── App.vue

├── main.ts

├── composables/

│ └── useNui.ts # NUI communication layer

└── components/


Step 2: Initialize the Vue Project

bash
cd my-resource

npm create vite@latest web -- --template vue-ts

cd web

npm install

Configure Vite for FiveM

FiveM NUI loads files from the resource directory. Vite needs to output to the resource root:

typescript
class="hl-comment">// web/vite.config.ts
import { defineConfig } from &class="hl-comment">#class="hl-number">039;vite&#class="hl-number">039;
import vue from &class="hl-comment">#class="hl-number">039;@vitejs/plugin-vue&#class="hl-number">039;

export default defineConfig({

plugins: [vue()],

base: &class="hl-comment">#class="hl-number">039;./&#class="hl-number">039;, // Relative paths for FiveM

build: {

outDir: &class="hl-comment">#class="hl-number">039;../html&#class="hl-number">039;, // Build output goes to resource/html/

emptyOutDir: true,

assetsDir: &class="hl-comment">#class="hl-number">039;./&#class="hl-number">039;,

rollupOptions: {

output: {

entryFileNames: &class="hl-comment">#class="hl-number">039;[name].js&#class="hl-number">039;,

chunkFileNames: &class="hl-comment">#class="hl-number">039;[name].js&#class="hl-number">039;,

assetFileNames: &class="hl-comment">#class="hl-number">039;[name].[ext]&#class="hl-number">039;,

},

},

},

server: {

port: class="hl-number">3000,

},

})

fxmanifest.lua

lua
fx_version 'cerulean'

game 'gta5'

author 'YourName'

description 'My Custom NUI Resource'

version '1.0.0'

ui_page 'html/index.html'

files {

'html/index.html',

'html/*.js',

'html/*.css',

}

client_scripts {

'client/main.lua',

}

server_scripts {

'server/main.lua',

}


Step 3: The NUI Communication Layer

This is the most important part. FiveM communicates with NUI via SendNUIMessage (Lua to JS) and RegisterNUICallback (JS to Lua).

Create the useNui Composable

typescript
class="hl-comment">// web/src/composables/useNui.ts
import { onMounted, onUnmounted } from &class="hl-comment">#class="hl-number">039;vue&#class="hl-number">039;

interface NuiMessage<T = any> {

action: string

data?: T

}

class="hl-comment">// Send data back to Lua via NUI callback export async function fetchNui<T = any>(

eventName: string,

data?: any

): Promise<T> {

const resourceName = (window as any).GetParentResourceName

? (window as any).GetParentResourceName()

: &class="hl-comment">#class="hl-number">039;my-resource&#class="hl-number">039;

const resp = await fetch(&class="hl-comment">#class="hl-number">039;https://class="hl-string">&#class="hl-number">039; + resourceName + &#class="hl-number">039;/&#class="hl-number">039; + eventName, {

method: &class="hl-comment">#class="hl-number">039;POST&#class="hl-number">039;,

headers: { &class="hl-comment">#class="hl-number">039;Content-Typeclass="hl-string">&#class="hl-number">039;: &#class="hl-number">039;application/json&#class="hl-number">039; },

body: JSON.stringify(data ?? {}),

})

return resp.json() as T

}

class="hl-comment">// Listen for messages from Lua export function useNuiEvent<T = any>(

action: string,

handler: (data: T) => void

) {

const listener = (event: MessageEvent<NuiMessage<T>>) => {

if (event.data.action === action) {

handler(event.data.data as T)

}

}

onMounted(() => window.addEventListener(&class="hl-comment">#class="hl-number">039;message&#class="hl-number">039;, listener))

onUnmounted(() => window.removeEventListener(&class="hl-comment">#class="hl-number">039;message&#class="hl-number">039;, listener))

}

Usage in a Vue Component

typescript
class="hl-comment">// web/src/App.vue - script setup
import { ref } from &class="hl-comment">#class="hl-number">039;vue&#class="hl-number">039;
import { useNuiEvent, fetchNui } from &class="hl-comment">#class="hl-number">039;./composables/useNui&#class="hl-number">039;

const visible = ref(false)
const playerName = ref(&class="hl-comment">#class="hl-number">039;&#class="hl-number">039;)

class="hl-comment">// Listen for Lua to show/hide the UI
useNuiEvent(&class="hl-comment">#class="hl-number">039;toggleUI&#class="hl-number">039;, (data: { show: boolean; name: string }) => {

visible.value = data.show

playerName.value = data.name

})

class="hl-comment">// Send data back to Lua async function submitForm(jobName: string) {

const result = await fetchNui(&class="hl-comment">#class="hl-number">039;submitJob&#class="hl-number">039;, { name: jobName })

console.log(&class="hl-comment">#class="hl-number">039;Server responded:&#class="hl-number">039;, result)

}

class="hl-comment">// Close on Escape key function handleKeydown(e: KeyboardEvent) {

if (e.key === &class="hl-comment">#class="hl-number">039;Escape&#class="hl-number">039;) {

visible.value = false

fetchNui(&class="hl-comment">#class="hl-number">039;closeUI&#class="hl-number">039;)

}

}


Step 4: Lua Side — Sending and Receiving

Open the NUI from Lua

lua
-- client/main.lua
local isOpen = false

RegisterCommand(&#039;openmenu&#039;, function()

if isOpen then return end

isOpen = true

SetNuiFocus(true, true)

SendNUIMessage({

action = &#039;toggleUI&#039;,

data = {

show = true,

name = GetPlayerName(PlayerId()),

},

})

end, false) -- Handle the close callback from Vue RegisterNUICallback(&#039;closeUI&#039;, function(_, cb)

isOpen = false

SetNuiFocus(false, false)

cb(&#039;ok&#039;)

end) -- Handle form submission from Vue RegisterNUICallback(&#039;submitJob&#039;, function(data, cb)

local jobName = data.name

-- Send to server for processing

TriggerServerEvent(&#039;myresource:applyJob&#039;, jobName)

cb({ success = true, message = &#039;Application submitted&#039; })

end)

Critical: Always call cb() in RegisterNUICallback. If you do not call the callback, the fetch promise in Vue hangs indefinitely and can cause memory leaks.


Step 5: Development Workflow

During development, you want hot reload. Run the Vite dev server alongside FiveM:

bash
cd my-resource/web

npm run dev

Temporarily change your fxmanifest.lua to point to the dev server:

lua
-- Development only:

ui_page &#039;http://localhost:3000&#039;

-- Production: -- ui_page &#039;html/index.html&#039;

Now every change you make in Vue is reflected instantly in-game without restarting the resource.

Warning: Never ship with the dev server URL. Always build for production before deploying:

bash
cd web

npm run build

This outputs optimized files to my-resource/html/.


Step 6: Styling for FiveM

NUI has some quirks compared to regular browsers:

Transparent Background

The NUI page overlays the game. Your root element must be transparent:

css
/* web/src/style.css */

html, body, #app {

margin: 0;

padding: 0;

width: 100vw;

height: 100vh;

background: transparent;

font-family: &#039;Inter&#039;, sans-serif;

color: #fff;

overflow: hidden;

}

Prevent Mouse Bleed-Through

When NUI has focus, mouse clicks pass through transparent areas to the game. Prevent this with a full-screen event catcher:

css
.nui-wrapper {

position: fixed;

inset: 0;

display: flex;

align-items: center;

justify-content: center;

/ This catches mouse events so they don&#039;t reach the game /

background: rgba(0, 0, 0, 0.5);

}

Font Loading

FiveM's CEF does not have system fonts beyond basics. Bundle your fonts:

css
@font-face {

font-family: &#039;Inter&#039;;

src: url(&#039;./fonts/Inter-Regular.woff2&#039;) format(&#039;woff2&#039;);

font-weight: 400;

font-display: swap;

}


Step 7: Production Optimization

Minimize Bundle Size

Every kilobyte matters — FiveM loads NUI for each resource that has one. Keep your dependencies lean:

bash
# Check your bundle size

npx vite-bundle-visualizer

Rules of thumb:

  • Avoid UI libraries like Vuetify or Element Plus — they add 200KB+ you do not need
  • Use Tailwind CSS or hand-written CSS — minimal overhead
  • Tree-shake imports: import { ref, computed } from 'vue' instead of import Vue from 'vue'
  • Lazy-load heavy components with defineAsyncComponent

Destroy the NUI When Hidden

As covered in our QBCore FPS optimization guide, NUI frames consume GPU even when invisible. Clean up properly when your UI is hidden — stop all animations, clear intervals, and release resources.


Real Example: How Alone Scripts Uses NUI

The Jobs Creator tablet interface is built with Vue 3 and Vite following exactly this architecture. The tablet panel — where admins create jobs, manage employees, and configure shops — is a full Vue 3 SPA running inside FiveM's NUI layer.

The Alone Imposter minigame uses Vue 3 NUI for the lobby system, voting interface, and player cards. Every NUI frame is destroyed when the minigame ends, freeing GPU resources completely.

→ See our scripts in action

Ready to Transform Your Server?

FiveM Job Creator eliminates every problem discussed in this article. 0.00ms resmon. No-code configuration. ESX & QBCore native.

Get Job Creator — €29.99