Structuring a React NUI resource for FiveM
Keep the Lua in client/ and server/, the React app in web/ (Vite, TypeScript), and point ui_page at web/build/index.html with web/build/**/* in files. Use a useNuiEvent(action, handler) hook for Lua messages sent as { action, data }, a fetchNui helper for callbacks, debugData to fake Lua messages in the browser, and isEnvBrowser() (!window.invokeNative) to detect development. Build with base: './'.
Most modern FiveM UIs — phones, inventories, tablets — are React apps built with Vite. The community’s reference starting point is Project Error’s React + Lua boilerplate. Understanding its structure pays off even if you start from scratch, because the same four helpers show up in almost every serious NUI.
The layout
my_ui/
fxmanifest.lua
client/
client.lua
utils.lua -- SendReactMessage helper
server/
server.lua
web/
index.html
vite.config.ts -- base: './', outDir: 'build'
src/
main.tsx
components/App.tsx
hooks/useNuiEvent.ts
providers/VisibilityProvider.tsx
utils/fetchNui.ts
utils/debugData.ts
utils/misc.ts -- isEnvBrowser
build/ -- generatedfx_version 'cerulean'
game 'gta5'
lua54 'yes'
ui_page 'web/build/index.html'
client_script 'client/**/*'
server_script 'server/**/*'
files {
'web/build/index.html',
'web/build/**/*',
}Messages: Lua → React
function SendReactMessage(action, data)
SendNUIMessage({ action = action, data = data })
endimport { useState } from 'react';
import { useNuiEvent } from '../hooks/useNuiEvent';
export default function App() {
const [balance, setBalance] = useState(0);
useNuiEvent<number>('setBalance', setBalance);
return <div className="card">${balance}</div>;
}useNuiEvent adds one message listener per action and removes it when the component unmounts. The { action, data } convention keeps routing simple as the UI grows. Basics: NUI basics.
Callbacks: React → Lua
import { isEnvBrowser } from './misc';
export async function fetchNui<T = unknown>(event: string, data?: unknown, mock?: T): Promise<T> {
if (isEnvBrowser() && mock !== undefined) return mock;
const resource = (window as any).GetParentResourceName ? (window as any).GetParentResourceName() : 'nui-frame-app';
const res = await fetch(`https://${resource}/${event}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
body: JSON.stringify(data),
});
return res.json();
}The Lua side registers each event with RegisterNuiCallback — details and pitfalls in NUI callbacks.
Developing in the browser
import { debugData } from './utils/debugData';
debugData([
{ action: 'setVisible', data: true },
{ action: 'setBalance', data: 12500 },
]);debugData only runs in development mode outside the game (isEnvBrowser() checks for window.invokeNative, which exists only inside FiveM). npm run dev gives hot reload in Chrome; npm run build produces web/build for the game.
Visibility and focus
A visibility provider listens for a setVisible action, renders nothing when hidden, and on Escape calls a hideFrame callback so Lua can release focus with SetNuiFocus(false, false). Focus rules: NUI focus and cursor.
Growing the structure
- One folder per screen (
features/bank,features/garage) with its own components and hooks. - A small store (Zustand, Jotai or context) for shared state; keep fast-changing values local.
- Type your message payloads once and share them between
useNuiEventandfetchNuicalls. - Keep the Lua side thin: validate on the server, send UI-ready data to React.
Performance habits for React UIs are in NUI performance.
Common questions
What is the best way to start a React NUI for FiveM?
Start from a React + Vite + TypeScript boilerplate such as Project Error’s, which includes useNuiEvent, fetchNui and browser debugging helpers.
How does React receive data from Lua?
Lua calls SendNUIMessage({ action, data }); a useNuiEvent(action, handler) hook passes data to your component.
How do I test the UI without the game?
Run the Vite dev server and use debugData to dispatch fake Lua messages; mock callback responses in fetchNui.
Why is my built React UI blank in game?
Usually absolute asset paths. Set Vite base: './' and make sure web/build/**/* is in files.
Want a loading screen you never have to debug?
Build it in the browser, export once, and edit it whenever — no HTML, no re-uploads.
Start free