Build a Custom Phone App (NUI + React) for QBCore/ESX fro…
Goal
Create a production‑ready in‑game smartphone for FiveM using NUI + React. You will scaffold a resource, wire QBCore/ESX events, persist data in MySQL, and ship a smooth UI that respects performance budgets.
Prerequisites
- A running FiveM server with txAdmin and MySQL (oxmysql or mysql-async).
- Node.js 18+ and pnpm or npm on your dev PC.
- One framework installed: QBCore or ESX.
- Recommended libs: ox_lib (callbacks, notifications), ox_inventory (optional for phone item), ox_target (optional for world interactions).
- Basic React knowledge.
Docs
- Cfx.re NUI overview – https://docs.fivem.net/docs/scripting-manual/nui-development/
- NUI callbacks – https://docs.fivem.net/docs/scripting-manual/nui-development/nui-callbacks/
- SendNUIMessage – https://docs.fivem.net/docs/scripting-reference/runtimes/lua/functions/SendNUIMessage/
- Debug NUI devtools – https://docs.fivem.net/docs/scripting-manual/nui-development/full-screen-nui/
- QBCore server functions – https://docs.qbcore.org/qbcore-documentation/qb-core/server-function-reference
- ESX
RegisterUsableItem– https://docs.esx-framework.org/en/esx_core/es_extended/server/functions - ox_lib callbacks – https://overextended.dev/ox_lib
Internal reading (FiveMX)
- Resmon & performance – https://fivemx.com/how-to-use-resmon-in-fivem-optimize-resources/
- Performance hub – https://fivemx.com/performance
- Phone scripts market overview – https://fivemx.com/phone-scripts
Architecture
- Resource
my_phonewithfxmanifest.lua,client,server, anduibundle. - UI: React app built with Vite into
/ui/dist. NUI talks to Lua viapostMessage+RegisterNUICallback. - Data: MySQL tables for
phone_contacts,phone_messages,phone_calls. - Framework glue: QBCore or ESX item usable handler toggles the phone, and server callbacks load/save data.
Event flow
- Player presses key or uses the phone item → 2)
SetNuiFocus(true, true)andSendNUIMessage({ action = 'open' })→ 3) React shows UI → 4) UI requests data viafetch('https://my_phone/xyz')(NUI) → 5)RegisterNUICallback('xyz', ...)runs on client/server → 6) Server reads/writes DB → 7) Response returns to UI → 8) Close phone and release focus.
Step 1 — Scaffold the resource
Folder layout
resources/
[local]/
my_phone/
fxmanifest.lua
client/
main.lua
server/
main.lua
ui/
index.html
src/
main.tsx
App.tsx
api.ts
styles.css
fxmanifest.lua
fx_version 'cerulean'
game 'gta5'
ui_page 'ui/dist/index.html'
files { 'ui/dist/**' }
client_scripts { 'client/main.lua' }
server_scripts {
'@oxmysql/lib/MySQL.lua',
'server/main.lua'
}
lua54 'yes'
Step 2 — Create the React NUI
Initialize a Vite React app inside my_phone/ui and build to ui/dist.
cd my_phone/ui pnpm create vite@latest . --template react-ts pnpm i
Vite config (ensure assets land in dist)
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
build: { outDir: 'dist', emptyOutDir: true },
base: ''
})
NUI bridge
// src/api.ts
export async function nui<T>(event: string, data?: unknown): Promise<T> {
const res = await fetch(`https://my_phone/${event}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data ?? {})
})
return await res.json()
}
Mount React + message listener
// src/main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
const root = ReactDOM.createRoot(document.getElementById('root')!)
root.render(<App />)
window.addEventListener('message', (e) => {
if (e.data?.action === 'open') document.body.classList.add('open')
if (e.data?.action === 'close') document.body.classList.remove('open')
})
Basic UI
// src/App.tsx
import { useEffect, useState } from 'react'
import { nui } from './api'
type Contact = { id: number; name: string; number: string }
export default function App() {
const [contacts, setContacts] = useState<Contact[]>([])
const [visible, setVisible] = useState(false)
useEffect(() => {
const handler = (e: MessageEvent) => {
if (e.data?.action === 'open') {
setVisible(true)
nui<Contact[]>('contacts:list').then(setContacts)
}
if (e.data?.action === 'close') setVisible(false)
}
window.addEventListener('message', handler)
return () => window.removeEventListener('message', handler)
}, [])
if (!visible) return null
return (
<div className="phone">
<header>Phone</header>
<section>
{contacts.map(c => (
<div key={c.id} className="row">
<div>{c.name}</div>
<div>{c.number}</div>
</div>
))}
</section>
<footer>
<button onClick={() => nui('ui:close')}>Close</button>
</footer>
</div>
)
}
index.html
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>my_phone</title>
<link rel="stylesheet" href="/src/styles.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Build the UI:
pnpm build
Step 3 — Client: open/close, NUI focus, callbacks
-- client/main.lua
local open = false
local function openPhone()
if open then return end
open = true
SetNuiFocus(true, true)
SendNUIMessage({ action = 'open' })
end
local function closePhone()
if not open then return end
open = false
SetNuiFocus(false, false)
SendNUIMessage({ action = 'close' })
end
-- Keybind (F1 example)
RegisterCommand('myphone', function()
if open then closePhone() else openPhone() end
end)
RegisterKeyMapping('myphone', 'Toggle Phone', 'keyboard', 'F1')
-- NUI → game callbacks
RegisterNUICallback('ui:close', function(_, cb)
closePhone()
cb({ ok = true })
end)
-- list contacts asks the server
RegisterNUICallback('contacts:list', function(_, cb)
lib.callback('my_phone:server:getContacts', false, function(rows)
cb(rows)
end)
end)
Tip: enable NUI devtools in game console with
nui_devTools. Openhttp://localhost:13172in your Chromium browser to inspect the UI.
Step 4 — Server: DB schema + callbacks
SQL (MySQL)
CREATE TABLE IF NOT EXISTS phone_contacts ( id INT AUTO_INCREMENT PRIMARY KEY, citizenid VARCHAR(64) NOT NULL, name VARCHAR(64) NOT NULL, number VARCHAR(32) NOT NULL, INDEX(citizenid) ); CREATE TABLE IF NOT EXISTS phone_messages ( id BIGINT AUTO_INCREMENT PRIMARY KEY, owner VARCHAR(64) NOT NULL, peer VARCHAR(64) NOT NULL, body TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX(owner), INDEX(peer) );
Server with oxmysql + ox_lib
-- server/main.lua
local QBCore = exports['qb-core'] and exports['qb-core']:GetCoreObject()
ESX = ESX or nil
if not QBCore then
TriggerEvent('esx:getSharedObject', function(obj) ESX = obj end)
end
-- Load contacts for the logged-in character
lib.callback.register('my_phone:server:getContacts', function(source)
local citizenid
if QBCore then
local Player = QBCore.Functions.GetPlayer(source)
citizenid = Player and Player.PlayerData.citizenid
else
local xPlayer = ESX.GetPlayerFromId(source)
citizenid = xPlayer and xPlayer.identifier
end
if not citizenid then return {} end
local rows = MySQL.query.await('SELECT id, name, number FROM phone_contacts WHERE citizenid = ?', { citizenid })
return rows or {}
end)
-- Save a contact
lib.callback.register('my_phone:server:addContact', function(source, contact)
if type(contact) ~= 'table' then return { ok = false } end
local name, number = contact.name, contact.number
if not name or not number then return { ok = false } end
local citizenid
if QBCore then
local Player = QBCore.Functions.GetPlayer(source)
citizenid = Player and Player.PlayerData.citizenid
else
local xPlayer = ESX.GetPlayerFromId(source)
citizenid = xPlayer and xPlayer.identifier
end
if not citizenid then return { ok = false } end
MySQL.insert.await('INSERT INTO phone_contacts (citizenid, name, number) VALUES (?, ?, ?)', { citizenid, name, number })
return { ok = true }
end)
Step 5 — Framework integration (item + permissions)
QBCore
Add a usable phone item and toggle the UI when used.
-- server/main.lua (QBCore only)
if QBCore then
QBCore.Functions.CreateUseableItem('phone', function(src, item)
TriggerClientEvent('my_phone:client:toggle', src)
end)
end
-- client/main.lua
RegisterNetEvent('my_phone:client:toggle', function()
if IsPauseMenuActive() then return end
if IsPedInAnyVehicle(PlayerPedId(), false) then -- optional rule
-- show a notification via ox_lib
lib.notify({ title = 'Phone', description = 'No phone while driving.', type = 'error' })
return
end
if IsNuiFocused() then ExecuteCommand('myphone') else ExecuteCommand('myphone') end
end)
ESX
-- server/main.lua (ESX only)
if ESX and not QBCore then
ESX.RegisterUsableItem('phone', function(playerId)
TriggerClientEvent('my_phone:client:toggle', playerId)
end)
end
If you use ox_inventory, create the item there and rely on its usable handlers. You can still trigger the same client event.
Step 6 — Core features
Implement small slices and ship incrementally.
Contacts
- UI calls
contacts:list→ server returns rows. - Add “Add contact” form → call
addContact. - Add “Remove contact” → server deletes by
idwith citizen ownership check.
Messages (SMS)
- Table
phone_messagesstores owner, peer, body. - UI opens a chat, calls
messages:listandmessages:send. - Server inserts message, optionally emits client event to peer if online.
Server sketch
lib.callback.register('my_phone:server:messages:list', function(source, peer)
local cid = GetCitizenId(source)
return MySQL.query.await('SELECT * FROM phone_messages WHERE owner=? AND peer=? ORDER BY id DESC LIMIT 200', { cid, peer }) or {}
end)
RegisterNetEvent('my_phone:server:messages:send', function(peer, body)
local src = source
local cid = GetCitizenId(src)
if type(body) ~= 'string' or #body == 0 or #body > 500 then return end
MySQL.insert.await('INSERT INTO phone_messages (owner, peer, body) VALUES (?, ?, ?)', { cid, peer, body })
TriggerClientEvent('my_phone:client:messages:push', src, peer, body)
-- optional: find target player by phone number and push live event
end)
Client receive
RegisterNetEvent('my_phone:client:messages:push', function(peer, body)
SendNUIMessage({ action = 'message:new', peer = peer, body = body })
end)
Calls (optional MVP)
- Store call logs only. Real audio uses your voice plugin (pma-voice, mumble, SaltyChat) and is outside this MVP.
- Add UI keypad → on dial, log an outgoing call; on answer, log incoming. You can integrate later with a voice plugin’s API.
Step 7 — Security, UX, performance
Security
- Never trust NUI input. Validate types and length on server.
- Check ownership on every query with
citizenidoridentifier. - Avoid exposing identifiers to other clients. Use server relays.
UX
- Cancel phone while downed, cuffed, or driving, if your server rules require it.
- Keep UI snappy. Use optimistic updates and reconcile on server ack.
Performance
- Keep NUI idle. Avoid setInterval loops in React. Use effects and events.
- Keep bundles small. Lazy‑load heavy screens. Ship compressed assets.
- Use Resmon to budget under 0.01–0.02 ms on average. See FiveMX guide linked above.
Step 8 — Testing & debugging
- Start resource in
server.cfgbefore dependent scripts.
ensure my_phone
- In game, press F8 → run
nui_devTools→ openhttp://localhost:13172and pick your NUI page. - Inspect network tab. Every NUI → Lua call hits
https://my_phone/<name>endpoints. - Use
/myphonecommand and confirm focus toggles. - Run Resmon and verify CPU stays low while the phone is open and closed.
Step 9 — Packaging & updates
- Commit
ui/source andui/dist/build. - In CI, run
pnpm --filter ui buildand ship onlydistin releases. - Version your SQL migrations. Never drop user data without backups.
Step 10 — Extensions you can add next
- Banking: link to your server’s banking resource; expose balance and transfers.
- Tweets/Ads: global feed with rate limits and moderation.
- Marketplace: listings with escrow.
- Job apps: police/EMS MDT hooks.
- Photos: screenshot integration via server endpoint, not data URLs.
- Settings: dynamic themes, ringtones, backgrounds.
Troubleshooting
Phone opens behind pause menu
Disable during pause checks and reopen when active returns.
NUI callback not firing
- Ensure
RegisterNUICallback('event', ...)names match the UI fetch path. - Confirm
fx_versionisceruleanandui_pagepoints toui/dist/index.html. - Check F8 console for CORS or JSON errors.
Items not usable
- QBCore: confirm
QBCore.Functions.CreateUseableItemruns andphoneexists in your item list. - ESX: confirm
ESX.RegisterUsableItem('phone', ...)registers after inventory loads.
Database errors
- Ensure oxmysql started before this resource.
- Check column sizes and encodings for Unicode names.
Reference snippets (copy‑paste)
Helper
function GetCitizenId(source)
if QBCore then
local P = QBCore.Functions.GetPlayer(source)
return P and P.PlayerData.citizenid
else
local xP = ESX.GetPlayerFromId(source)
return xP and xP.identifier
end
end
Add contact from UI
// UI
async function addContact(name: string, number: string) {
const res = await nui<{ ok: boolean }>('contacts:add', { name, number })
if (res.ok) {
const next = await nui<any[]>('contacts:list')
// update state
}
}
-- client
RegisterNUICallback('contacts:add', function(data, cb)
lib.callback('my_phone:server:addContact', false, function(resp)
cb(resp)
end, data)
end)
What you built
- A focused phone MVP with contacts and messages.
- A clean NUI bridge that works on both frameworks.
- A DB layer you can expand safely.
Ship it, measure performance, and iterate.
Further reading
- FiveMX Resmon & optimization: https://fivemx.com/how-to-use-resmon-in-fivem-optimize-resources/
- Voice comparison (choose your stack): https://fivemx.com/fivem-voice-mumble-saltychat-pma-voice-guide
- Existing phones for inspiration:
- lb‑phone v2: https://fivemx.com/lb-phone-v2
- Quasar Smartphone: https://fivemx.com/quasar-smartphone
- GCPhone: https://fivemx.com/gcphone
- Z‑Phone: https://fivemx.com/z-phone
Meta
- Target resmon: ≤0.02 ms average while idle.
- Bundle budget: ≤250 KB gzipped for MVP.
- UI FPS: 60.






