Save 20% today Use code WELCOME at checkout. WELCOME

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

  1. A running FiveM server with txAdmin and MySQL (oxmysql or mysql-async).
  2. Node.js 18+ and pnpm or npm on your dev PC.
  3. One framework installed: QBCore or ESX.
  4. Recommended libs: ox_lib (callbacks, notifications), ox_inventory (optional for phone item), ox_target (optional for world interactions).
  5. Basic React knowledge.

Docs

Internal reading (FiveMX)


Architecture

  1. Resource my_phone with fxmanifest.lua, client, server, and ui bundle.
  2. UI: React app built with Vite into /ui/dist. NUI talks to Lua via postMessage + RegisterNUICallback.
  3. Data: MySQL tables for phone_contacts, phone_messages, phone_calls.
  4. Framework glue: QBCore or ESX item usable handler toggles the phone, and server callbacks load/save data.

Event flow

  1. Player presses key or uses the phone item → 2) SetNuiFocus(true, true) and SendNUIMessage({ action = 'open' }) → 3) React shows UI → 4) UI requests data via fetch('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. Open http://localhost:13172 in 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

  1. UI calls contacts:list → server returns rows.
  2. Add “Add contact” form → call addContact.
  3. Add “Remove contact” → server deletes by id with citizen ownership check.

Messages (SMS)

  1. Table phone_messages stores owner, peer, body.
  2. UI opens a chat, calls messages:list and messages:send.
  3. 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

  1. Never trust NUI input. Validate types and length on server.
  2. Check ownership on every query with citizenid or identifier.
  3. Avoid exposing identifiers to other clients. Use server relays.

UX

  1. Cancel phone while downed, cuffed, or driving, if your server rules require it.
  2. Keep UI snappy. Use optimistic updates and reconcile on server ack.

Performance

  1. Keep NUI idle. Avoid setInterval loops in React. Use effects and events.
  2. Keep bundles small. Lazy‑load heavy screens. Ship compressed assets.
  3. Use Resmon to budget under 0.01–0.02 ms on average. See FiveMX guide linked above.

Step 8 — Testing & debugging

  1. Start resource in server.cfg before dependent scripts.
ensure my_phone
  1. In game, press F8 → run nui_devTools → open http://localhost:13172 and pick your NUI page.
  2. Inspect network tab. Every NUI → Lua call hits https://my_phone/<name> endpoints.
  3. Use /myphone command and confirm focus toggles.
  4. Run Resmon and verify CPU stays low while the phone is open and closed.

Step 9 — Packaging & updates

  1. Commit ui/ source and ui/dist/ build.
  2. In CI, run pnpm --filter ui build and ship only dist in releases.
  3. Version your SQL migrations. Never drop user data without backups.

Step 10 — Extensions you can add next

  1. Banking: link to your server’s banking resource; expose balance and transfers.
  2. Tweets/Ads: global feed with rate limits and moderation.
  3. Marketplace: listings with escrow.
  4. Job apps: police/EMS MDT hooks.
  5. Photos: screenshot integration via server endpoint, not data URLs.
  6. 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_version is cerulean and ui_page points to ui/dist/index.html.
  • Check F8 console for CORS or JSON errors.

Items not usable

  • QBCore: confirm QBCore.Functions.CreateUseableItem runs and phone exists 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


Meta

  • Target resmon: ≤0.02 ms average while idle.
  • Bundle budget: ≤250 KB gzipped for MVP.
  • UI FPS: 60.
Luke
Luke

I'm Luke, I am a gamer and love to write about FiveM, GTA, and roleplay. I run a roleplay community and have about 10 years of experience in administering servers.

Articles: 570