Skip to Content
QBCore docs – powered by Nextra 4
GuidesGuide: NUI Form with Callback

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:

  1. Registering a QBCore callback on the server
  2. Creating a minimal HTML/CSS interface
  3. Using JavaScript to call the callback and handle responses
  4. 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

  1. Start your resource and run /report in-game
  2. Submit a valid form and confirm the success notification
  3. Try invalid data (short citizen ID, empty details) and confirm errors
  4. Trigger the cancel button and ensure focus returns to the game
  5. 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

Last updated on