Goal
Create a polished NUI form that collects data from the player, validates it, sends it to the server using QBCore.Functions.CreateCallback
, and displays a response to the user.
Overview
We’ll build a small resource that opens a modal form, captures input, and performs a secure server-side action. The workflow covers:
- Registering a QBCore callback on the server
- Creating a minimal HTML/CSS interface
- Using JavaScript to call the callback and handle responses
- Closing the UI cleanly and notifying the player
Resource Layout
resources/[local]/qb-nui-form/
├── fxmanifest.lua
├── client.lua
├── server.lua
└── html/
├── index.html
└── script.js
Step-by-Step Implementation
1. Define the resource manifest
fxmanifest.lua
fx_version 'cerulean'
game 'gta5'
ui_page 'html/index.html'
files {
'html/index.html',
'html/script.js',
'html/styles.css'
}
shared_scripts {
'@qb-core/shared/locale.lua',
'config.lua'
}
client_script 'client.lua'
server_script 'server.lua'
2. Build the NUI form
html/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Submit Incident Report</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div class="modal">
<h2>Incident Report</h2>
<form id="incident-form">
<label>Citizen ID
<input type="text" name="citizenId" required minlength="4" />
</label>
<label>Category
<select name="category" required>
<option value="traffic">Traffic</option>
<option value="property">Property</option>
<option value="violent">Violent</option>
</select>
</label>
<label>Description
<textarea name="details" rows="4" required></textarea>
</label>
<div class="actions">
<button type="submit">Submit</button>
<button type="button" id="cancel">Cancel</button>
</div>
<p id="feedback" role="alert"></p>
</form>
</div>
<script src="script.js"></script>
</body>
</html>
3. Send form data to the server
html/script.js
const closeUI = () => {
fetch(`https://${GetParentResourceName()}/close`, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
body: JSON.stringify({})
});
};
document.getElementById('incident-form').addEventListener('submit', async (event) => {
event.preventDefault();
const formData = new FormData(event.target);
const payload = Object.fromEntries(formData.entries());
const response = await fetch(`https://${GetParentResourceName()}/submit-incident`, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
body: JSON.stringify(payload)
});
const result = await response.json();
const feedback = document.getElementById('feedback');
if (result.success) {
feedback.textContent = result.message;
feedback.className = 'success';
setTimeout(closeUI, 1500);
} else {
feedback.textContent = result.error;
feedback.className = 'error';
}
});
document.getElementById('cancel').addEventListener('click', closeUI);
window.addEventListener('keyup', (event) => {
if (event.key === 'Escape') {
closeUI();
}
});
4. Handle UI state on the client
client.lua
local showing = false
RegisterCommand('report', function()
if showing then return end
showing = true
SetNuiFocus(true, true)
SendNUIMessage({ action = 'open' })
end)
RegisterNUICallback('close', function(_, cb)
SetNuiFocus(false, false)
showing = false
cb({})
end)
RegisterNUICallback('submit-incident', function(data, cb)
QBCore.Functions.TriggerCallback('qb-nui-form:submitIncident', function(response)
cb(response)
if response.success then
TriggerEvent('QBCore:Notify', response.message, 'success')
else
TriggerEvent('QBCore:Notify', response.error, 'error')
end
end, data)
end)
5. Validate and process server-side
server.lua
local QBCore = exports['qb-core']:GetCoreObject()
local allowedCategories = {
traffic = true,
property = true,
violent = true
}
QBCore.Functions.CreateCallback('qb-nui-form:submitIncident', function(source, cb, data)
local player = QBCore.Functions.GetPlayer(source)
if not player then
cb({ success = false, error = 'Unable to identify player.' })
return
end
if type(data.citizenId) ~= 'string' or #data.citizenId < 4 then
cb({ success = false, error = 'Citizen ID is invalid.' })
return
end
if not allowedCategories[data.category] then
cb({ success = false, error = 'Invalid category selected.' })
return
end
local sanitizedDetails = string.sub((data.details or ''):gsub('%s+', ' '), 1, 500)
if sanitizedDetails == '' then
cb({ success = false, error = 'Description cannot be empty.' })
return
end
-- Save data or trigger downstream events
print(('Incident from %s (%s): %s'):format(player.PlayerData.name, data.citizenId, sanitizedDetails))
cb({ success = true, message = 'Report submitted for review.' })
end)
🛡️
Always validate payloads on the server. Never trust client-provided data, and rate-limit callbacks if the form can be spammed.
Testing Checklist
- Start your resource and run
/report
in-game - Submit a valid form and confirm the success notification
- Try invalid data (short citizen ID, empty details) and confirm errors
- Trigger the cancel button and ensure focus returns to the game
- Review the server console output to confirm the payload is sanitized
Troubleshooting
- UI does not open – ensure the command is registered and the resource name matches
GetParentResourceName()
- Callback never returns – verify the callback name matches on client and server, and that you call
cb()
in all branches - Focus stuck – call
SetNuiFocus(false, false)
on cancel and after success
Next Steps
- Strengthen validation with the Safe Server Events guide
- Monitor resource usage with Resmon Basics
- Expand the UI with reusable components from the Scripting tutorial