DocumentationGuidesNUI 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