Eigene Phone-App bauen (NUI + React) für QBCore/ESX
Schritt-für-Schritt-Tutorial zum Erstellen eines In-Game-Smartphones für FiveM. Mit NUI + React, QBCore/ESX-Events und MySQL-Datenpersistenz.

Einführung
Ziel Erstelle ein produktionsreifes In-Game-Smartphone für FiveM mit NUI + React. Du richtest eine Ressource ein, verdrahtest QBCore/ESX-Events, persistierst Daten in MySQL und lieferst eine flüssige UI, die Performance-Budgets einhält.
Dieser Guide ist Teil unseres umfassenden FiveM-Content-Creation-Guides, der alles von MLO-Design über Scripting, Fahrzeug-Modding bis hin zum Aufbau deiner Creator-Marke abdeckt.
Voraussetzungen
- Ein laufender FiveM-Server mit txAdmin und MySQL (oxmysql oder mysql-async).
- Node.js 18+ und pnpm oder npm auf deinem Entwicklungs-PC.
- Ein installiertes Framework: QBCore oder ESX.
- Empfohlene Libs: ox_lib (Callbacks, Benachrichtigungen), ox_inventory (optional für Phone-Item), ox_target (optional für Weltinteraktionen).
- Grundlegende React-Kenntnisse.
Docs
- Cfx.re NUI-Übersicht – 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-Serverfunktionen – 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
Interne Lektüre (FiveMX)
- Resmon & Performance – https://vertexmods.com/de/blog/how-to-use-resmon-in-fivem-optimize-resources/
- Performance-Hub – https://vertexmods.com/performance
- Phone-Scripts-Marktübersicht – https://vertexmods.com/de/blog/build-a-custom-phone-app
Architektur
- Ressource
my_phonemitfxmanifest.lua,client,serverundui-Bundle. - UI: React-App, mit Vite in
/ui/distgebaut. NUI kommuniziert mit Lua viapostMessage+RegisterNUICallback. - Daten: MySQL-Tabellen für
phone_contacts,phone_messages,phone_calls. - Framework-Glue: QBCore oder ESX Item-Handler schaltet das Phone um, Server-Callbacks laden/speichern Daten.
Event-Ablauf
- Spieler drückt Taste oder benutzt Phone-Item → 2)
SetNuiFocus(true, true)undSendNUIMessage({ action = 'open' })→ 3) React zeigt UI → 4) UI fordert Daten viafetch('https://my_phone/xyz')(NUI) an → 5)RegisterNUICallback('xyz', ...)läuft auf Client/Server → 6) Server liest/schreibt DB → 7) Antwort kehrt zur UI zurück → 8) Phone schließen und Fokus freigeben.
Schritt 1 — Ressource einrichten
Ordnerstruktur
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'
Schritt 2 — React NUI erstellen
Initialisiere eine Vite-React-App in my_phone/ui und baue in ui/dist.
cd my_phone/ui pnpm create vite@latest . --template react-ts pnpm i
Vite-Konfiguration (Assets landen 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()
}
React mounten + 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')
})
Basis-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')}>Schließen</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>
UI bauen:
pnpm build
Schritt 3 — Client: Öffnen/Schließen, NUI-Fokus, 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-Beispiel)
RegisterCommand('myphone', function()
if open then closePhone() else openPhone() end
end)
RegisterKeyMapping('myphone', 'Phone umschalten', 'keyboard', 'F1')
-- NUI → Game-Callbacks
RegisterNUICallback('ui:close', function(_, cb)
closePhone()
cb({ ok = true })
end)
-- Kontakte-Liste fragt den Server
RegisterNUICallback('contacts:list', function(_, cb)
lib.callback('my_phone:server:getContacts', false, function(rows)
cb(rows)
end)
end)
Tipp: Aktiviere NUI-Devtools in der In-Game-Konsole mit
nui_devTools. Öffnehttp://localhost:13172in deinem Chromium-Browser, um die UI zu inspizieren.
Schritt 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 mit 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
-- Kontakte für den eingeloggten Charakter laden
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)
-- Kontakt speichern
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)
Schritt 5 — Framework-Integration (Item + Berechtigungen)
QBCore
Füge ein nutzbares Phone-Item hinzu und schalte die UI beim Benutzen um.
-- server/main.lua (nur QBCore)
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 -- optionale Regel
-- Benachrichtigung via ox_lib anzeigen
lib.notify({ title = 'Phone', description = 'Kein Phone beim Fahren.', type = 'error' })
return
end
if IsNuiFocused() then ExecuteCommand('myphone') else ExecuteCommand('myphone') end
end)
ESX
-- server/main.lua (nur ESX)
if ESX and not QBCore then
ESX.RegisterUsableItem('phone', function(playerId)
TriggerClientEvent('my_phone:client:toggle', playerId)
end)
end
Wenn du ox_inventory nutzt, erstelle das Item dort und verlasse dich auf seine nutzbaren Handler. Du kannst trotzdem dasselbe Client-Event auslösen.
Schritt 6 — Kernfunktionen
Implementiere kleine Teile und liefere inkrementell.
Kontakte
- UI ruft
contacts:listauf → Server gibt Zeilen zurück. - "Kontakt hinzufügen"-Formular hinzufügen →
addContactaufrufen. - "Kontakt entfernen" hinzufügen → Server löscht nach
idmit Citizen-Eigentumscheck.
Nachrichten (SMS)
- Tabelle
phone_messagesspeichert Owner, Peer, Body. - UI öffnet Chat, ruft
messages:listundmessages:sendauf. - Server fügt Nachricht ein, sendet optional Client-Event an Peer wenn online.
Server-Skizze
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: Ziel-Spieler nach Telefonnummer suchen und Live-Event pushen
end)
Client empfangen
RegisterNetEvent('my_phone:client:messages:push', function(peer, body)
SendNUIMessage({ action = 'message:new', peer = peer, body = body })
end)
Anrufe (optionales MVP)
- Nur Anruf-Logs speichern. Echtes Audio nutzt dein Voice-Plugin (pma-voice, mumble, SaltyChat) und liegt außerhalb dieses MVPs.
- UI-Wähltastatur hinzufügen → beim Wählen ausgehenden Anruf loggen; beim Annehmen eingehenden loggen.
Schritt 7 — Sicherheit, UX, Performance
Sicherheit
- Vertraue niemals NUI-Input. Typen und Länge serverseitig validieren.
- Eigentumscheck bei jeder Abfrage mit
citizenidoderidentifier. - Keine Identifier an andere Clients weitergeben. Server-Relays nutzen.
UX
- Phone deaktivieren wenn bewusstlos, gefesselt oder fahrend, wenn die Serverregeln es erfordern.
- UI reaktionsschnell halten. Optimistische Updates nutzen und bei Server-Ack abgleichen.
Performance
- NUI im Leerlauf halten. Keine setInterval-Loops in React. Effects und Events nutzen.
- Bundles klein halten. Schwere Screens lazy-laden. Komprimierte Assets liefern.
- Resmon nutzen, um unter 0.01–0.02 ms im Durchschnitt zu bleiben.
Schritt 8 — Testen & Debuggen
- Ressource in
server.cfgvor abhängigen Scripts starten.
ensure my_phone
- Im Spiel F8 drücken →
nui_devToolsausführen →http://localhost:13172öffnen und deine NUI-Seite auswählen. - Netzwerk-Tab inspizieren. Jeder NUI → Lua-Aufruf trifft
https://my_phone/<name>-Endpoints. /myphone-Befehl nutzen und bestätigen, dass der Fokus umschaltet.- Resmon ausführen und verifizieren, dass die CPU bei geöffnetem und geschlossenem Phone niedrig bleibt.
Schritt 9 — Paketierung & Updates
ui/-Quellcode undui/dist/-Build committen.- In CI
pnpm --filter ui buildausführen und nurdistin Releases ausliefern. - SQL-Migrationen versionieren. Niemals User-Daten ohne Backups löschen.
Schritt 10 — Erweiterungen für später
- Banking: Verknüpfung mit deiner Server-Banking-Ressource; Kontostand und Überweisungen anzeigen.
- Tweets/Ads: Globaler Feed mit Rate-Limits und Moderation.
- Marketplace: Listings mit Treuhand.
- Job-Apps: Polizei/EMS-MDT-Hooks.
- Fotos: Screenshot-Integration via Server-Endpoint, keine Data-URLs.
- Einstellungen: Dynamische Themes, Klingeltöne, Hintergründe.
Fehlerbehebung
Phone öffnet sich hinter dem Pause-Menü
Phone öffnet sich hinter dem Pause-Menü Während Pause-Checks deaktivieren und beim Rückkehren wieder öffnen.
NUI-Callback feuert nicht
- Sicherstellen, dass
RegisterNUICallback('event', ...)-Namen mit dem UI-Fetch-Pfad übereinstimmen. - Bestätigen, dass
fx_versionceruleanist undui_pageaufui/dist/index.htmlzeigt. - F8-Konsole auf CORS- oder JSON-Fehler prüfen.
Items nicht nutzbar
- QBCore: Bestätigen, dass
QBCore.Functions.CreateUseableItemausgeführt wird undphonein deiner Item-Liste existiert. - ESX: Bestätigen, dass
ESX.RegisterUsableItem('phone', ...)nach dem Inventar-Load registriert.
Datenbankfehler
- Sicherstellen, dass oxmysql vor dieser Ressource gestartet wurde.
- Spaltengrößen und Encodings für Unicode-Namen prüfen.
Referenz-Snippets (zum Kopieren)
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
Kontakt aus UI hinzufügen
// 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')
// State aktualisieren
}
}
-- client
RegisterNUICallback('contacts:add', function(data, cb)
lib.callback('my_phone:server:addContact', false, function(resp)
cb(resp)
end, data)
end)
Was du gebaut hast
- Ein fokussiertes Phone-MVP mit Kontakten und Nachrichten.
- Eine saubere NUI-Bridge, die auf beiden Frameworks funktioniert.
- Eine DB-Schicht, die du sicher erweitern kannst.
Veröffentliche es, messe die Performance und iteriere.
Weiterführende Lektüre
- FiveMX Resmon & Optimierung: https://vertexmods.com/de/blog/how-to-use-resmon-in-fivem-optimize-resources/
- Voice-Vergleich (wähle deinen Stack): https://vertexmods.com/de/blog/fivem-voice-mumble-saltychat-pma-voice-guide
- Bestehende Phones zur Inspiration:
- lb-phone v2: https://vertexmods.com/de/shop/lb-phone-v2
- Quasar Smartphone: https://vertexmods.com/de/shop
Meta
- Ziel-Resmon: ≤0.02 ms durchschnittlich im Leerlauf.
- Bundle-Budget: ≤250 KB gzipped für MVP.
- UI-FPS: 60.
Verwandte Guides
Schau dir unsere Experten-Guides an:

