Skip to main content
Home
Shop
Free Mods
Tools
Bundles
Full Servers
  1. Home
  2. Blog
  3. Development

Build a Custom Phone App (NUI + React) for QBCore/ESX

Published on October 4, 2025·by Lars Miller(Founder & Lead Editor)·Credentials·6 min read·Updated on March 24, 2026
Developmentbuild custom phone app (nui react)

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…

Build a Custom Phone App (NUI + React) for QBCore/ESX
Build a Custom Phone App (NUI + React) for QBCore/ESX

Introduction

FiveM Phone Scripts Comparison and Features

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

  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

  • 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/blog/how-to-use-resmon-in-fivem-optimize-resources/
  • Performance hub – https://fivemx.com/performance
  • Phone scripts market overview – https://fivemx.com/blog/build-a-custom-phone-app

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'

More info about: fxmanifest.lua


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

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

  • FiveMX Resmon & optimization: https://fivemx.com/blog/how-to-use-resmon-in-fivem-optimize-resources/
  • Voice comparison (choose your stack): https://fivemx.com/blog/fivem-voice-mumble-saltychat-pma-voice-guide
  • Existing phones for inspiration:
  • lb‑phone v2: https://fivemx.com/shop/lb-phone-v2
  • GCPhone: https://fivemx.com/gcphone
  • Z‑Phone: https://fivemx.com/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:

  • ESX vs QBCore vs QBOX Comparison 2026
  • Phone Script Comparison 2026
  • Economy Script Comparison

Frequently Asked Questions

If I'm using ESX, how does this phone app integrate with usable items?

The guide will walk you through leveraging ESX's `RegisterUsableItem` function. This essential ESX function allows players to trigger the phone's NUI interface by using a designated item, creating a realistic and immersive interaction within the game. The phone item can also be integrated with `ox_inventory`, allowing for a more advanced inventory system.

What's the best way to handle potential conflicts between the phone's NUI and other UI elements, like the pause menu?

The guide addresses the common issue of the phone's NUI opening behind the pause menu. The solution involves disabling the phone's NUI during pause menu activity and re-enabling it once the player returns to the active game state. This process can be achieved by checking if pause is being used and then re-opening the phone NUI.

What if the NUI callback isn't firing correctly when I interact with the phone's interface?

If the NUI callback isn't firing, the guide suggests examining your JavaScript console to ensure that callbacks are firing between the client and server with the correct data. It's essential to check both client-side JavaScript code and server-side Lua code to ensure they are correctly structured and that the event names match. Double-check your `SendNUIMessage` calls and the corresponding event listeners.

Why does this guide recommend Node.js 18+ specifically?

Node.js 18+ is recommended to leverage modern JavaScript features and ensure compatibility with the latest versions of React and other related libraries, which means this will ensure that there are no issues while creating the phone app with the most up-to-date tooling. Staying current with Node.js versions will provide access to the latest performance improvements and security patches.

What is 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.

Previous Article

FiveM: How To Play (Full Guide)

Next Article

FivePD — The Complete Beginner’s Guide (Setup, Gamepl...

More on This Topic

Best FiveM Phone Scripts 2026: Complete Comparison GuideFiveM Phone Scripts Troubleshooting FAQ: 12 Common Issues FixedHow to Stream Custom Clothing in FiveMBest FiveM Loading Screen Scripts 2026 — Custom Screens That ImpressHow To Install Custom Cars (FiveM)

Turn framework research into a launch-ready script stack

Use this guide to narrow the framework decision, then move into the core commercial hubs for verified scripts, curated bundles, and a faster server launch path.

Framework hub

Browse QBCore-ready scripts

Move into the QBCore landing page to compare verified scripts, framework fit, and install-ready products built for modern FiveM servers.

Open QBCore hub

Framework hub

Review the ESX script path

Use the ESX landing page to compare framework-specific resources, launch guidance, and premium products that fit ESX-first servers.

Open ESX hub

Premium catalog

Browse premium FiveM scripts

Move from research into the main shop to compare real products, framework labels, screenshots, and production-ready quality signals.

Open premium shop

Launch faster

Compare curated bundles

Bundles shorten the path from planning to launch by grouping the highest-leverage scripts into a cleaner commercial starting point.

View bundles

Disclosure: Some links below are affiliate links to FiveMX products. We may earn a commission at no extra cost to you.

Premium Scripts You Might Like

ESX Menu Design

ESX Menu Design

$8.99
ESX Inventory HUD V16

ESX Inventory HUD V16

$13.99
ESX Plugin For EasyAdmin

ESX Plugin For EasyAdmin

$7.99
ESX Enhanced Barber

ESX Enhanced Barber

$21.99

Free Scripts You Might Like

Project X Prompt Sandy Bank Robbery Heist - QB | QBOX | ESX | Custom

Project X Prompt Sandy Bank Robbery Heist - QB | QBOX | ESX | Custom

294 downloads
Realistic Grapple Gun - Nodus Scripts

Realistic Grapple Gun - Nodus Scripts

202 downloads
OP Gangs 3.0 — Most Advanced Gang Script [ESX/QB/QBOX]

OP Gangs 3.0 — Most Advanced Gang Script [ESX/QB/QBOX]

161 downloads
[FREE][QBOX][QBCORE] Pawnshop Script + FREE MLO

[FREE][QBOX][QBCORE] Pawnshop Script + FREE MLO

156 downloads

Related Articles

SQL & Identifiers Migration: steam/license to citizenid

SQL & Identifiers Migration: steam/license to citizenid

1. Freeze writes during migration (stop the game server + any external bots touching DB). 2. Full backup and a dump of table structures. Store both with timestamps. 3.

August 16, 2025
How to Create a Tebex Store for FiveM

How to Create a Tebex Store for FiveM

Ready to turn your FiveM or Discord community into a profit‑making hub? Our step‑by‑step guide walks you through setting up a Tebex store, choosing a stunning design, adding game‑boosting perks, and m

February 5, 2024
How To Create an alt:V Server (2026 Quickstart Guide)

How To Create an alt:V Server (2026 Quickstart Guide)

Want to host your own GTA V multiplayer world with alt:V? This guide shows you two reliable setup paths (Windows & Linux), gives you a clean server.toml, a first working…

September 22, 2025
Secure CheckoutInstant AccessMoney-Back GuaranteeLifetime Updates
FiveMX

Premium FiveM scripts and mods for serious server owners.

Shop

  • Shop
  • QBCore Scripts
  • ESX Scripts
  • FiveM Scripts
  • Free Mods
  • Best Scripts & Mods

Help

  • About
  • FAQ
  • Support
  • Contact
  • Account
  • Affiliate Program

Legal

  • Privacy Policy
  • Terms of Service
  • Refund Policy
  • Cookie Policy
  • GDPR Compliance
  • DMCA
  • Imprint
  • Editorial Policy
© 2026 FiveMX. All rights reserved.·support@fivemx.com

FiveMX is not affiliated with Rockstar Games, Take-Two Interactive, or CFX.re. All trademarks are property of their respective owners.

Flash Sale — Up to 19% off!Flash Sale — 19% off!Shop Now