Production NUI: a React and Vite build pipeline
In the from-scratch lesson you wrote three loose files and FiveM served them as-is. That stops scaling the moment you want React, TypeScript, or any npm package. Production NUI is a real frontend project: you write source under ui/, run a build step that bundles everything into a dist/ folder, and the resource serves that built output. This lesson wires that pipeline end to end with React and Vite, then drills the one workflow detail everyone trips on: editing source does nothing in-game until you rebuild.
Build it
Make the resource and the ui source folder
Inside your server's resources folder, create this layout. The ui/ folder is your React project; everything outside it is the FiveM resource:
resources/qu_nui_react/
fxmanifest.lua
client.lua
ui/
index.html
package.json
vite.config.js
src/
main.jsx
App.jsx
You will not create dist/ by hand. The build step in Step 6 generates it. That separation is the whole point: ui/ is source you edit, dist/ is the compiled bundle the game actually loads.
Write the ui project files
Open ui/package.json and paste this:
{
"name": "qu-nui-react",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^6.0.2",
"vite": "^8.0.0"
}
}
Open ui/vite.config.js and paste this:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
base: './',
build: {
outDir: 'dist',
assetsDir: 'assets'
}
});
Open ui/index.html and paste this:
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>qu_nui_react</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
Write the React app
Open ui/src/main.jsx and paste this:
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
createRoot(document.getElementById('root')).render(<App />);
Open ui/src/App.jsx and paste this:
import React, { useEffect, useState } from 'react';
function nuiPost(endpoint, body) {
return fetch(`https://${GetParentResourceName()}/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
}
export default function App() {
const [open, setOpen] = useState(false);
const [balance, setBalance] = useState(0);
useEffect(() => {
function onMessage(event) {
const msg = event.data;
if (msg.type === 'open') {
setBalance(msg.balance);
setOpen(true);
}
}
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, []);
function close() {
nuiPost('close', {}).then(() => setOpen(false));
}
if (!open) return null;
return (
<div style={{
position: 'fixed', inset: 0, display: 'grid', placeItems: 'center',
fontFamily: 'sans-serif'
}}>
<div style={{ background: '#14161a', color: '#fff', padding: 28, borderRadius: 12 }}>
<h1>Phone</h1>
<p>Balance: ${balance}</p>
<button onClick={close}>Close</button>
</div>
</div>
);
}
Write client.lua
Open client.lua and paste this:
RegisterCommand('phone', function()
SetNuiFocus(true, true)
SendNUIMessage({ type = 'open', balance = 4200 })
print('[qu_nui_react] open sent, focus captured')
end, false)
RegisterNUICallback('close', function(data, cb)
SetNuiFocus(false, false)
print('[qu_nui_react] close callback, focus released')
cb({ ok = true })
end)
Write fxmanifest.lua pointing at the build output
Open fxmanifest.lua and paste this:
fx_version 'cerulean'
game 'gta5'
client_script 'client.lua'
ui_page 'ui/dist/index.html'
files {
'ui/dist/index.html',
'ui/dist/assets/*.js',
'ui/dist/assets/*.css'
}
Read the paths carefully. ui_page and every line in files point at ui/dist, the compiled output, never at ui/src. The game cannot run .jsx or import npm packages. It can only load the plain HTML, JS, and CSS that Vite emits into dist/. The assets/*.js and assets/*.css globs whitelist the hashed bundle filenames Vite produces, so you do not have to rename files every build.
Build the ui, then start the resource
Open a terminal on the machine where your server files live. On Windows that is PowerShell (Start menu, type 'PowerShell'); on Linux or macOS it is the Terminal app; inside VS Code it is the integrated terminal (View > Terminal). The first command below moves into the project: it assumes you start from your server's resources folder, so adjust the path if you started somewhere else. Then install dependencies and build once:
cd resources/qu_nui_react/ui
npm install
npm run build
After the build, confirm ui/dist/index.html and a ui/dist/assets/ folder now exist. That is the artifact ui_page will serve.
Open server.cfg and add this line:
ensure qu_nui_react
Save. Then, in the server console (the FXServer window, or the txAdmin Live Console in your browser), type:
restart qu_nui_react
Join the server. Then press T to open the in-game chat box and run this test:
Type /phone in the chat box and press Enter. The React panel appears. Then click the Close button.
On screen the React panel reads Balance: $4200, the value Lua pushed in the open message. Clicking Close hides the panel and releases focus.
Keep reading the full lesson
Sign in to start, then unlock every step of this lesson and the full FiveM School with a membership.
- How it works
- If something went wrong
- What you can do now
- Try it yourself
The remainder of NUI advanced: React and Vite is available to FiveM School members.