NUI: interfaces over the game
NUI is the web layer FiveM draws over the game: your menus, HUD and phones are HTML, CSS and JavaScript talking to Lua.
Have you noticed that FiveM's pretty menus (the HUD, the phone, the clothing store) don't look like they belong to the game, but like a web page? That's because they are. FiveM embeds a browser (CEF, the same Chromium engine as Chrome) and draws it on top of the game. That layer is called NUI (New UI). You program it with what you already know from the web: HTML for structure, CSS for looks and JavaScript for logic.
The key thing about NUI is that it lives on the CLIENT: it's the player's browser. That's why it needs a bridge to talk to your Lua script, and through it, to the server. In this chapter we build that bridge from start to finish with a coherent example: a panel that opens, shows data and sends an action back.
1. Declare the interface in the fxmanifest
The first thing is to tell FiveM which page is yours and which web files it must serve. Two things: ui_page points to the main HTML, and files{} lists EVERYTHING the browser needs to load (html, css, js, images...). If a file isn't in files{}, the browser won't find it.
fx_version 'cerulean'
game 'gta5'
author 'Crxative-M'
description 'Panel NUI de ejemplo'
version '1.0.0'
client_scripts {
'client.lua'
}
server_scripts {
'server.lua'
}
-- The page drawn over the game
ui_page 'html/index.html'
-- Everything the browser must be able to load
files {
'html/index.html',
'html/style.css',
'html/app.js'
}fxmanifest.lua
The paths in files{} are relative to the resource root. If your HTML is at html/index.html, the ui_page and files{} must use exactly that path. One misplaced slash = blank screen.
2. The HTML: hidden by default
The NUI loads when the resource starts and stays ALWAYS on top of the game. That's why your interface must be born hidden (display:none) and only show when Lua asks for it. Otherwise you'd have a panel covering the screen the whole time.
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<!-- Hidden until Lua sends 'open' -->
<div id="panel" style="display:none">
<h1 id="titulo"></h1>
<p>Dinero: <span id="dinero"></span>$</p>
<button id="btnCobrar">Cobrar sueldo</button>
<button id="btnCerrar">Cerrar</button>
</div>
<script src="app.js"></script>
</body>
</html>html/index.html
3. From Lua to JavaScript: SendNUIMessage
To talk to the interface, Lua uses SendNUIMessage with a table. That table arrives in JavaScript as an object. The universal convention is to include an action field that says WHAT to do, and the rest of the data alongside it. And very important: if you want the player to be able to move the mouse and click, you have to give it focus with SetNuiFocus(true, true).
-- client.lua
RegisterCommand('panel', function()
-- We show the cursor and give focus to the NUI
SetNuiFocus(true, true)
-- We send the order to open, with data to render
SendNUIMessage({
action = 'open',
titulo = 'Panel del trabajador',
dinero = 1500
})
end, false)client.lua, open the panel
SetNuiFocus(true, true): the first true gives focus (the game stops capturing keyboard/mouse for the NUI), the second shows the cursor. For a HUD that only informs and receives no clicks, don't give focus: let the player keep playing.
4. JavaScript listens for the message
On the web side, messages from Lua arrive as a message event on window. Inside e.data you have the table you sent. You read e.data.action and act accordingly.
// app.js
const panel = document.getElementById('panel');
window.addEventListener('message', (e) => {
const data = e.data;
if (data.action === 'open') {
document.getElementById('titulo').textContent = data.titulo;
document.getElementById('dinero').textContent = data.dinero;
panel.style.display = 'block';
}
if (data.action === 'close') {
panel.style.display = 'none';
}
});app.js, receive from Lua and render
5. From JavaScript to Lua: fetch to the callback
When the player presses a button, the JavaScript passes the ball back to Lua with a fetch. The URL always has the form https://RESOURCE_NAME/callbackName, and that resource name is given by the GetParentResourceName() function. Never write it by hand: if you rename the resource, it would stop working.
// app.js, continued
document.getElementById('btnCobrar').addEventListener('click', () => {
fetch(`https://${GetParentResourceName()}/cobrarSueldo`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cantidad: 250 })
})
.then((resp) => resp.json())
.then((res) => console.log('Lua replied:', res));
});
document.getElementById('btnCerrar').addEventListener('click', () => {
// We ask Lua to remove our focus and close
fetch(`https://${GetParentResourceName()}/cerrar`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
});app.js, send to Lua
6. Lua receives with RegisterNUICallback
Every fetch needs its partner in Lua: a RegisterNUICallback with the SAME name. The function receives data (what you sent in the body) and cb, a response function you must ALWAYS call to close the cycle (even if it's with cb('ok')). This is where we ask the server for the important stuff and where we release the focus.
-- client.lua
RegisterNUICallback('cobrarSueldo', function(data, cb)
-- We do NOT give money here: the client is not trusted.
-- We only notify the server so it validates and decides.
TriggerServerEvent('miscript:cobrarSueldo', data.cantidad)
cb('ok') -- always respond to the fetch
end)
RegisterNUICallback('cerrar', function(data, cb)
SetNuiFocus(false, false) -- we release focus and cursor
SendNUIMessage({ action = 'close' }) -- we hide the panel
cb('ok')
end)client.lua, receive from the NUI
7. The server is in charge
Golden rule: the NUI and the client are NOT trusted. Any player can open the DevTools and fire your fetch with whatever amount they want. That's why money, items and permissions are ALWAYS decided on the server, which is your machine and the only authority. The client only asks; the server checks and grants.
-- server.lua
RegisterNetEvent('miscript:cobrarSueldo', function(cantidad)
local src = source -- who requested it (cannot be faked)
-- We validate on the server: never trust the client's number
if type(cantidad) ~= 'number' or cantidad <= 0 or cantidad > 250 then
return -- suspicious attempt: we ignore it
end
-- Here you'd give the money for real (ESX, QBCore, your DB...)
-- Player(src).addMoney(cantidad)
print(('Jugador %s cobró %s$'):format(src, cantidad))
end)server.lua, the real validation
The full flow, at a glance
- 1) The player types /panel → Lua gives focus with SetNuiFocus(true,true) and sends action:'open' with SendNUIMessage.
- 2) JavaScript listens for the message, renders the data and shows the panel.
- 3) The player presses 'Collect' → JavaScript does a fetch to https://resource/cobrarSueldo.
- 4) RegisterNUICallback receives the fetch, notifies the SERVER with TriggerServerEvent and calls cb('ok').
- 5) The server VALIDATES the request and grants (or ignores) the money.
- 6) On close, Lua does SetNuiFocus(false,false) and sends action:'close' to hide the panel.
Typical mistakes (and how to avoid them)
- Forgetting SetNuiFocus(false, false) on close: the cursor stays stuck and the player can't move or look around. It's THE classic NUI bug.
- Wrong paths in ui_page or files{}: if the path doesn't exactly match the real folder, you get a blank screen or the CSS/JS won't load.
- Writing the resource name by hand in the fetch instead of using GetParentResourceName(): it breaks the moment you rename the resource.
- Validating on the client instead of the server: giving money/items in RegisterNUICallback is an open door for cheaters. Always decide in server.lua.
- Forgetting to call cb(...) in the callback: the fetch hangs waiting for a response.
Debugging NUI: CEF has its own DevTools. With the resource running, open http://localhost:13172 in your browser (or use the FiveM console) to see the console, the JavaScript errors and the Network tab of your fetches. Focus trick: if you get stuck with the cursor jammed because of a bug, type in the client's F8 console: SetNuiFocus(false, false) to free yourself while you test.
Put it into practice
Got a question about this? The AI chat knows all of it and answers with code.
Ask the AI