
Build a Custom Phone App (NUI + React) for QBCore/ESX
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.
This guide is part of our complete FiveM content creation guide, covering everything from MLO design to scripting, vehicle modding, and building your creator brand.
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://vertexmods.com/en/blog/how-to-use-resmon-in-fivem-optimize-resources/
- Performance hub – https://vertexmods.com/performance
- Phone scripts market overview – https://vertexmods.com/en/blog/build-a-custom-phone-app
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://vertexmods.com/en/blog/how-to-use-resmon-in-fivem-optimize-resources/
- Voice comparison (choose your stack): https://vertexmods.com/en/blog/fivem-voice-mumble-saltychat-pma-voice-guide
- Existing phones for inspiration:
- lb‑phone v2: https://vertexmods.com/en/shop/lb-phone-v2
- Quasar Smartphone: https://vertexmods.com/en/shop
- GCPhone: https://vertexmods.com/gcphone
- Z‑Phone: https://vertexmods.com/en/shop
Meta
- Target resmon: ≤0.02 ms average while idle.
- Bundle budget: ≤250 KB gzipped for MVP.
- UI FPS: 60.
📖 Related Guides
Looking for the best resources? Check out our expert guides:
Bleib auf dem Laufenden
Erhalte die neuesten FiveM-Tutorials, Mod-Releases und exklusive Updates direkt in dein Postfach.
Kein Spam. Jederzeit abbestellbar.