NUI is where most beginners stall, because suddenly there is HTML sitting next to your Lua and it feels like a different job. It is not. You need five ideas and one rule: never trust the browser. This lesson gives you both, plus a sandbox you can poke without launching a server.
What NUI actually is
NUI is a Chromium browser glued on top of the game screen. Your resource ships a web page, FiveM renders it as a transparent layer over GTA, and you talk to it with messages. That is the whole model. The "UI" in your script is a web page floating above the world — invisible until you tell it to show itself.
There are exactly two directions of traffic. Learn them as a pair:
- Lua → browser:
SendNUIMessage - browser → Lua:
fetchto aRegisterNUICallback
Everything else is decoration.
The minimum web you need
Three files. HTML is structure, CSS is looks, JS is behavior. Here is a complete ID-card panel.
<!-- html/index.html -->
<!DOCTYPE html>
<html>
<head><link rel="stylesheet" href="style.css"></head>
<body>
<div id="card" class="hidden">
<h2 id="name"></h2>
<p id="job"></p>
<button id="close">Close</button>
</div>
<script src="app.js"></script>
</body>
</html>
/* html/style.css */
body { margin: 0; background: transparent; } /* the game must show through */
.hidden { display: none; } /* default state: invisible */
#card {
position: absolute; top: 20%; left: 40%;
padding: 20px; border-radius: 12px;
background: rgba(20,20,20,0.9); color: #fff;
font-family: sans-serif;
}
// html/app.js
const card = document.getElementById('card');
// Lua -> browser messages land here
window.addEventListener('message', (event) => {
const data = event.data;
if (data.action === 'showCard') {
document.getElementById('name').textContent = data.name;
document.getElementById('job').textContent = data.job;
card.classList.remove('hidden');
}
});
// browser -> Lua: tell the client to release focus
document.getElementById('close').addEventListener('click', () => {
card.classList.add('hidden');
fetch(`https://${GetParentResourceName()}/closeCard`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
});
The one trick that trips everyone: transparent body, hidden by default. Forget either and you get a black wall over your screen.
The manifest is the contract
CEF only loads files you explicitly list. Miss one and you get a blank panel and no error — the single most common beginner bug.
-- fxmanifest.lua
fx_version 'cerulean'
game 'gta5'
ui_page 'html/index.html'
files {
'html/index.html',
'html/style.css',
'html/app.js',
}
shared_script '@ox_lib/init.lua' -- modern, ox-first
client_scripts { 'client.lua' }
server_scripts { 'server.lua' }
dependencies { 'ox_lib' }
Two-way traffic: messages and callbacks
Here is the Lua side. Notice where authority lives: the server decides what the card says, the client only displays it.
-- client.lua
local showing = false
RegisterCommand('idcard', function()
if showing then return end
-- Ask the SERVER for the real data. Never build identity on the client.
local info = lib.callback.await('playdeck:getIdInfo', false)
if not info then return end
showing = true
SetNuiFocus(true, true) -- hand keyboard + mouse to the browser
SendNUIMessage({
action = 'showCard',
name = info.name,
job = info.job,
})
end, false)
-- The browser calls this when the player clicks Close.
RegisterNUICallback('closeCard', function(_, cb)
showing = false
SetNuiFocus(false, false) -- give control BACK to the game
cb('ok') -- always answer, or the fetch hangs forever
end)
-- server.lua
lib.callback.register('playdeck:getIdInfo', function(source)
local player = exports.qbx_core:GetPlayer(source) -- pin ONE framework
if not player then return nil end
local char = player.PlayerData.charinfo
return {
name = ('%s %s'):format(char.firstname, char.lastname),
job = player.PlayerData.job.label,
}
end)
SendNUIMessage posts a JSON table your JS reads in the message listener. RegisterNUICallback registers a name the browser hits with fetch. The URL https://<resource>/closeCard is not a real website — it is how CEF routes a fetch back into your Lua.
Focus, failure modes, and where AI gets it wrong
SetNuiFocus(hasFocus, hasCursor) decides whether input goes to the page or the game. The brutal bug: open with SetNuiFocus(true, true), then close the panel without setting it back to false, false. The player is now frozen — no movement, mouse trapped on an invisible page. Every "show" needs a matching "release."
The rest of the classic list:
- Blank panel? A file is missing from
files, or you forgot the page ishiddenby default. - Black screen? Body is not
background: transparent. - Fetch hangs? Your callback never called
cb(). - Old code keeps running? CEF caches — restart the resource after edits.
Now the AI traps, because you will use AI for this:
- It invents natives.
OpenNUI(),ShowUI(),DisplayMenu()do not exist. The real set is tiny:SendNUIMessage,RegisterNUICallback,SetNuiFocus,SetNuiFocusKeepInput. Verify every native against the official reference — link it, do not trust the model's memory. - It mixes frameworks. You will get
QBCore.Functions.GetPlayerin one file andESX.GetPlayerDatain the next. Pin one stack; we use Qbox andlib.callback, not the oldTriggerCallback. - It trusts the client. AI will happily read
data.jobfrom a NUI fetch and write it to your database. That payload is attacker-editable. Treat every byte from the browser as hostile and compute authority on the server.
Practice
No FiveM needed — use the PlayDeck browser sandbox, which fakes SendNUIMessage and NUI callbacks in a plain browser.
- Paste the three web files above into the sandbox.
- In the sandbox Lua console, fire
SendNUIMessage({action = 'showCard', name = 'Lee Ash', job = 'Mechanic'}). The card appears. - Click Close. Watch the console log a
closeCardfetch with its payload — that is your browser-to-Lua round trip. - Break it on purpose: change
action = 'showCard'toaction = 'show'. Nothing happens. That proves theactionstring is a contract both sides must agree on — the source of half of all "my UI does not open" tickets.
Recap
- NUI is a transparent browser over the game; you drive it with two channels.
SendNUIMessage(Lua → JS) andRegisterNUICallback+fetch(JS → Lua) are the whole conversation.- List every web file in
files, keep the body transparent, start hidden. - Pair every
SetNuiFocus(true, true)with aSetNuiFocus(false, false), and always callcb(). - The server owns the truth. AI invents natives, mixes frameworks, and trusts the browser — verify natives against the official reference, pin one framework, validate server-side, and test it all in the sandbox first.