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.
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:
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
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:
class="hl-comment">// web/vite.config.ts
import { defineConfig } from &class="hl-comment">#class="hl-number">039;viteclass="hl-number">039;
import vue from &class="hl-comment">#class="hl-number">039;@vitejs/plugin-vueclass="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;../htmlclass="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].jsclass="hl-number">039;,
chunkFileNames: &class="hl-comment">#class="hl-number">039;[name].jsclass="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
fx_version 'cerulean039;
game 'gta5039;
author 'YourName039;
description 'My Custom NUI Resource039;
version '1.0.0039;
ui_page 'html/index.html039;
files {
'html/index.html039;,
'html/*.js039;,
'html/*.css039;,
}
client_scripts {
'client/main.lua039;,
}
server_scripts {
'server/main.lua039;,
}
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
class="hl-comment">// web/src/composables/useNui.ts
import { onMounted, onUnmounted } from &class="hl-comment">#class="hl-number">039;vueclass="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-resourceclass="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;POSTclass="hl-number">039;,
headers: { &class="hl-comment">#class="hl-number">039;Content-Typeclass="hl-string">class="hl-number">039;: class="hl-number">039;application/jsonclass="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;messageclass="hl-number">039;, listener))
onUnmounted(() => window.removeEventListener(&class="hl-comment">#class="hl-number">039;messageclass="hl-number">039;, listener))
}
Usage in a Vue Component
class="hl-comment">// web/src/App.vue - script setup
import { ref } from &class="hl-comment">#class="hl-number">039;vueclass="hl-number">039;
import { useNuiEvent, fetchNui } from &class="hl-comment">#class="hl-number">039;./composables/useNuiclass="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;toggleUIclass="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;submitJobclass="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;Escapeclass="hl-number">039;) {
visible.value = false
fetchNui(&class="hl-comment">#class="hl-number">039;closeUIclass="hl-number">039;)
}
}
Step 4: Lua Side — Sending and Receiving
Open the NUI from Lua
-- client/main.lua
local isOpen = false
RegisterCommand('openmenu039;, function()
if isOpen then return end
isOpen = true
SetNuiFocus(true, true)
SendNUIMessage({
action = 'toggleUI039;,
data = {
show = true,
name = GetPlayerName(PlayerId()),
},
})
end, false)
-- Handle the close callback from Vue
RegisterNUICallback('closeUI039;, function(_, cb)
isOpen = false
SetNuiFocus(false, false)
cb('ok039;)
end)
-- Handle form submission from Vue
RegisterNUICallback('submitJob039;, function(data, cb)
local jobName = data.name
-- Send to server for processing
TriggerServerEvent('myresource:applyJob039;, jobName)
cb({ success = true, message = 'Application submitted039; })
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:
cd my-resource/web
npm run dev
Temporarily change your fxmanifest.lua to point to the dev server:
-- Development only:
ui_page 'http://localhost:3000039;
-- Production:
-- ui_page 039;html/index.html039;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:
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:
/* web/src/style.css */
html, body, #app {
margin: 0;
padding: 0;
width: 100vw;
height: 100vh;
background: transparent;
font-family: 'Inter039;, 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:
.nui-wrapper {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
/ This catches mouse events so they don'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:
@font-face {
font-family: 'Inter039;;
src: url('./fonts/Inter-Regular.woff2039;) format(039;woff2039;);
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:
# 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 ofimport 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.
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