FiveM NUI Guide — Build In-Game UIs
You want a custom UI in your FiveM server — maybe a phone, a shop menu, a notification system, or a job progress bar — and you’re wondering how to actually build one. NUI (short for “new UI”) is how FiveM renders web-based interfaces directly inside the game. It’s essentially a Chromium browser embedded in the client, which means anything you can build with HTML, CSS, and JavaScript can become an in-game interface.
I’ve built dozens of NUI panels across our scripts, and the good news is that the barrier to entry is low if you already know basic web development. The bad news is that there are FiveM-specific patterns you need to follow or your UI will either not show up, leak memory, or tank client performance.
How NUI Actually Works
Every FiveM resource can include an ui_page directive in its fxmanifest.lua that points to an HTML file. When the resource starts, the client loads that HTML file into an invisible browser frame. You control visibility, send data to it from Lua, and receive data back via callbacks.
Here’s the minimal setup in your manifest:
fx_version 'cerulean'
game 'gta5'
lua54 'yes'
client_script 'client.lua'
ui_page 'html/index.html'
files {
'html/index.html',
'html/style.css',
'html/app.js'
}
The ui_page tells the client which HTML file to render. The files block makes those assets available to the NUI browser. If you forget to list a file here, the browser can’t load it — this is the single most common mistake I see from beginners.
The Message Loop — Lua to JS and Back
NUI communication works through a simple message-passing system. Lua sends data to JavaScript, and JavaScript sends data back through HTTP callbacks. If you’ve read our guide on FiveM events, this pattern will feel familiar — except you’re crossing from Lua into a browser context instead of client to server.
Sending Data from Lua to JavaScript
-- client.lua
SendNUIMessage({
action = "openShop",
items = {
{ name = "bread", price = 5 },
{ name = "water", price = 3 }
}
})
SetNuiFocus(true, true) -- gives cursor + keyboard focus to NUI
Receiving That Data in JavaScript
// app.js
window.addEventListener('message', (event) => {
const data = event.data;
if (data.action === 'openShop') {
document.getElementById('shop').style.display = 'block';
renderItems(data.items);
}
if (data.action === 'closeShop') {
document.getElementById('shop').style.display = 'none';
}
});
Every SendNUIMessage call dispatches a message event to the browser window. The event.data object is whatever table you passed from Lua, serialized as JSON. Keep it simple — don’t send massive nested tables if you don’t have to.
Sending Data from JavaScript Back to Lua
This is where NUI callbacks come in. You register a callback in Lua and call it via a fetch request from JavaScript:
-- client.lua
RegisterNUICallback('buyItem', function(data, cb)
local itemName = data.itemName
local price = data.price
-- Trigger server event to handle purchase
TriggerServerEvent('shop:buyItem', itemName, price)
cb('ok') -- you MUST call cb() or the request hangs forever
end)
// app.js
async function buyItem(name, price) {
const response = await fetch(`https://${GetParentResourceName()}/buyItem`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ itemName: name, price: price })
});
const result = await response.json();
// handle result
}
GetParentResourceName() is a built-in NUI function that returns your resource name — it’s what makes the fetch URL resolve correctly. Never hardcode the resource name. If someone renames the folder, everything breaks.
Critical rule: always call cb() in your NUI callback. If you don’t, the HTTP connection stays open, and after enough unclosed connections the NUI browser starts behaving erratically.
Building a Real Example — A Notification System
Let’s build something practical: a notification popup that slides in from the top-right corner when triggered from Lua.
The HTML
<!-- html/index.html -->
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="notification-container"></div>
<script src="app.js"></script>
</body>
</html>
The CSS
/* html/style.css */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: transparent;
font-family: 'Segoe UI', sans-serif;
overflow: hidden;
}
#notification-container {
position: fixed;
top: 20px;
right: 20px;
display: flex;
flex-direction: column;
gap: 10px;
z-index: 9999;
}
.notification {
background: rgba(0, 0, 0, 0.85);
color: #fff;
padding: 14px 20px;
border-radius: 8px;
border-left: 4px solid #4CAF50;
min-width: 280px;
max-width: 350px;
transform: translateX(400px);
animation: slideIn 0.3s ease forwards;
}
.notification.error {
border-left-color: #f44336;
}
.notification.warning {
border-left-color: #ff9800;
}
.notification.leaving {
animation: slideOut 0.3s ease forwards;
}
@keyframes slideIn {
to { transform: translateX(0); }
}
@keyframes slideOut {
to { transform: translateX(400px); opacity: 0; }
}
The JavaScript
// html/app.js
window.addEventListener('message', (event) => {
if (event.data.action === 'showNotification') {
createNotification(event.data.message, event.data.type || 'success', event.data.duration || 5000);
}
});
function createNotification(message, type, duration) {
const container = document.getElementById('notification-container');
const el = document.createElement('div');
el.className = `notification ${type}`;
el.textContent = message;
container.appendChild(el);
setTimeout(() => {
el.classList.add('leaving');
el.addEventListener('animationend', () => el.remove());
}, duration);
}
The Lua Trigger
-- client.lua (export for other resources to use)
exports('notify', function(message, type, duration)
SendNUIMessage({
action = 'showNotification',
message = message,
type = type or 'success',
duration = duration or 5000
})
end)
Notice we’re not calling SetNuiFocus here. Notifications don’t need cursor input — the player should keep playing while they pop up. Only call SetNuiFocus(true, true) when you need the player to interact with the UI directly.
Handling Focus and Input Correctly
This is where most NUI scripts get annoying for players. If you steal focus and don’t give it back, the player is stuck unable to move. Here’s the pattern:
-- Opening UI
SendNUIMessage({ action = 'open' })
SetNuiFocus(true, true)
-- Closing UI (triggered by NUI callback)
RegisterNUICallback('close', function(_, cb)
SetNuiFocus(false, false)
SendNUIMessage({ action = 'close' })
cb('ok')
end)
And on the JavaScript side, always listen for Escape:
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
fetch(`https://${GetParentResourceName()}/close`, {
method: 'POST',
body: JSON.stringify({})
});
}
});
Never leave the player trapped in a UI with no way out. If your NUI errors out and doesn’t send the close callback, the player has to disconnect. That’s a terrible experience.
Performance Considerations
NUI panels run in a real browser, which means they consume real memory and CPU. I’ve seen servers where players report 10-15 FPS drops just from poorly written UIs. Here’s what I’ve learned building scripts like our TV Cinema Builder that relies heavily on NUI:
Keep the DOM small. Every element the browser has to render costs frames. If you have a list of 500 items, virtualize it — only render what’s visible.
Don’t animate constantly. CSS animations that run forever (spinning loaders, pulsing glows) consume GPU cycles even when the UI is hidden. Remove animated elements or pause them when the UI closes.
Hide properly. Setting display: none on your root container is better than opacity: 0. With opacity, the browser still composites the layer. With display none, it skips rendering entirely.
Avoid heavy frameworks for simple UIs. You don’t need React for a notification popup. Vanilla JavaScript with a few utility functions covers 80% of FiveM UI needs. If you’re building something complex like a full phone system or MDT, then a framework makes sense — but for menus, HUDs, and popups, keep it light.
Clean up event listeners. If you’re dynamically creating and destroying elements, remove their listeners. The NUI browser persists for the entire session — memory leaks accumulate over hours of play.
For more on diagnosing performance problems, our resmon guide walks through how to measure the actual impact of your resources on client and server.
Using Frameworks (When It Makes Sense)
For complex interfaces — MDTs, phone systems, tablet apps, inventory UIs — a framework like React or Vue can save you time. The build process looks like this:
- Develop your UI in a framework project (create-react-app, Vite, etc.)
- Build it to static HTML/CSS/JS
- Point your
ui_pageat the builtindex.html - Include all built files in your
filesblock
ui_page 'web/dist/index.html'
files {
'web/dist/index.html',
'web/dist/assets/*.js',
'web/dist/assets/*.css'
}
The trade-off is bundle size. A React app with dependencies can easily be 200-400KB of JavaScript that the NUI browser has to parse on load. For something the player opens once per session, that’s fine. For something that needs to render instantly on a keybind, it can feel sluggish.
If you’re new to FiveM development and still learning the basics of Lua scripting, I’d recommend starting with vanilla HTML/CSS/JS for NUI. Get comfortable with the message passing pattern first, then graduate to frameworks when your UI complexity demands it.
Debugging NUI
You can’t open Chrome DevTools inside FiveM directly, but there’s a workaround. Add this to your fxmanifest.lua:
-- Only during development, remove for production
nui_devtools 'true'
Then navigate to http://localhost:13172 in your regular Chrome browser while the game is running. You’ll get full DevTools access — inspect elements, watch network requests, set breakpoints, check console errors. This is invaluable when your UI isn’t rendering and you can’t figure out why.
Common NUI issues I debug regularly:
- Blank screen: Usually a missing file in the
filesblock, or a typo inui_pagepath - Messages not arriving: Check that your
window.addEventListener('message', ...)is actually registered (no JS errors before that line) - Callback hanging: You forgot to call
cb()in your Lua NUI callback handler - UI shows but no interaction: You didn’t call
SetNuiFocus(true, true)— or called it with(true, false)which gives cursor but no keyboard - Styling looks wrong: The NUI browser doesn’t load system fonts. Include any custom fonts via
@font-faceand add the font files to yourfilesblock
Structuring Larger NUI Projects
Once your UI grows past a single HTML file, you need a reasonable folder structure. Here’s what I use:
my-resource/
├── fxmanifest.lua
├── client.lua
├── server.lua
├── html/
│ ├── index.html
│ ├── css/
│ │ ├── reset.css
│ │ └── main.css
│ ├── js/
│ │ ├── app.js
│ │ ├── utils.js
│ │ └── components/
│ │ ├── shop.js
│ │ └── inventory.js
│ └── assets/
│ ├── fonts/
│ └── images/
Every file under html/ that the browser needs must be listed in files. You can use wildcards:
files {
'html/index.html',
'html/css/*.css',
'html/js/**/*.js',
'html/assets/**/*'
}
Making Your UI Feel Native
The best FiveM UIs don’t feel like web pages slapped over the game. They feel like they belong in the GTA world. A few tips:
Use semi-transparent backgrounds. rgba(0, 0, 0, 0.7) to rgba(0, 0, 0, 0.85) lets the game world bleed through without making text unreadable.
Match GTA’s typography. The game uses condensed, uppercase fonts for a lot of its native UI. Pricedown (the GTA title font) and Roboto Condensed work well for headers.
Animate subtly. Quick 200-300ms transitions for opens/closes. No bouncy easing, no 1-second fades. Players want snappy.
Respect screen real estate. Players are driving, shooting, running. Your UI shouldn’t block critical information. Put non-interactive elements (notifications, HUD info) at edges. Put interactive menus center-screen but keep them compact.
Sound feedback. You can play sounds from NUI using the HTML5 Audio API, but it’s better to trigger native GTA sounds from Lua via PlaySoundFrontend. They match the game’s audio mix and don’t compete with ambient sound.
RegisterNUICallback('playSound', function(data, cb)
PlaySoundFrontend(-1, data.sound, data.set, false)
cb('ok')
end)
Where to Go From Here
If this is your first NUI script, start with the notification example above. Get it working, customize the styling, add a few more notification types. Then try building a simple shop menu that sends purchase requests to the server.
Once you’re comfortable with the message loop and callbacks, you can tackle more complex projects. Our loading screen tutorial covers a specific NUI use case in detail, and the state bags guide explains how to sync data that your NUI might need to display in real-time.
For ready-made scripts that demonstrate polished NUI work, check our free scripts collection — several include full source NUI panels you can study and adapt. And if you get stuck, drop by the Discord — there’s always someone around who’s fought the same NUI bug you’re dealing with.
The FiveM NUI system is genuinely powerful. You have a full browser at your disposal inside a game engine. The ceiling is as high as your web development skills allow — from simple text popups to full 3D-rendered mini-maps built in Canvas. Start simple, profile often, and don’t overcomplicate things until you have to.