Skip to main content
TRACK B·INTERFACES (NUI)·Verified June 2026 · Lua 5.4 · ox_lib 3.x
Learning with an AI assistant?
Copies this lesson plus 2026 ground rules (no lua54 'yes', Cfx.re Portal, correct callback signatures) as a ready-to-paste mentor prompt.

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.

You'll build
A React + Vite NUI that opens on a command, shows one value Lua sent, and POSTs back to close. The resource serves a built dist folder, not loose HTML.
Time
~35 minutes
You need
The NUI from scratch lesson done so you know SendNUIMessage, RegisterNUICallback, and SetNuiFocus. Node 20.19+ or 22.12+ installed, and a server you can restart.
You'll learn
A ui/ source folder vs a built dist/ output, ui_page on the build artifact, typed Lua-to-React messages, the GetParentResourceName callback, and the build-then-refresh dev loop.
Pass condition
/phone opens a React panel showing a balance Lua pushed, the Close button POSTs to Lua, and F8 prints the round-trip.
Quasar School: NUI with a command (part 2).
BEFORE YOU START

Build it

Make the resource and the ui source folder

The resource holds a separate ui/ folder for frontend source.

Inside your server's resources folder, create this layout. The ui/ folder is your React project; everything outside it is the FiveM resource:

code
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

Vite knows how to bundle React into a relative-path dist.

Open ui/package.json and paste this:

code
{
"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:

code
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:

code
<!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

The app listens for a Lua message and POSTs back on close.

Open ui/src/main.jsx and paste this:

code
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:

code
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

Lua opens the panel with a typed message and answers the close callback.

Open client.lua and paste this:

code
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

The manifest serves dist/, not the ui/ source.

Open fxmanifest.lua and paste this:

code
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

A dist/ folder exists and the proof appears in F8.

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:

code
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:

code
ensure qu_nui_react

Save. Then, in the server console (the FXServer window, or the txAdmin Live Console in your browser), type:

code
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.

Still ahead in this lesson
  • 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.