FiveM NUI with React and Vite: setup, callbacks and browser dev

Build a FiveM NUI with React and Vite: ui_page and files in fxmanifest, base './', SendNUIMessage, RegisterNUICallback, SetNuiFocus and developing in the browser.

Plain HTML works for a small NUI, but a real interface is easier with React. The part that trips people up is not React, it is the glue: the manifest, the build output paths, and how messages go back and forth.

This article sets up a React and Vite NUI that loads in FiveM, talks to Lua in both directions, and can be built in a normal browser tab.

Project layout

Put the interface in a web folder inside the resource and create it with Vite:

bash
cd my_script
npm create vite@latest web -- --template react
cd web
npm install

The result:

text
my_script/
  fxmanifest.lua
  client/main.lua
  web/
    package.json
    vite.config.js
    src/
    dist/        (created by the build)

vite.config: base './'

FiveM loads your page from a nui:// address, not from a web server root. Vite's default asset paths start with /, which then point to nowhere and leave you with a blank page. Make them relative:

js
// web/vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  base: './',
  build: { outDir: 'dist' },
})

Then build:

bash
npm run build

fxmanifest.lua

Point ui_page at the built index.html and ship the whole dist folder to the player with files:

lua
fx_version 'cerulean'
game 'gta5'

ui_page 'web/dist/index.html'

files {
    'web/dist/index.html',
    'web/dist/**',
}

client_script 'client/main.lua'

Only the files listed in files are sent to players. If an image or a font is missing in game, the file glob did not include it. Keep node_modules and src out of the list: players do not need them.

Lua to the UI: SendNUIMessage

The client script sends a message and gives the interface focus:

lua
local open = false

local function setOpen(state)
    open = state
    SetNuiFocus(state, state)
    SendNUIMessage({ action = 'setVisible', visible = state, data = { name = 'Mic' } })
end

RegisterCommand('myui', function()
    setOpen(not open)
end, false)

SetNuiFocus(hasFocus, hasCursor) gives the keyboard and mouse to the page. Always give it back, or the cursor sticks: see NUI focus stuck cursor.

In React, listen for the message:

jsx
import { useEffect, useState } from 'react'

export default function App() {
  const [visible, setVisible] = useState(false)
  const [name, setName] = useState('')

  useEffect(() => {
    const onMessage = (event) => {
      const { action, visible, data } = event.data
      if (action === 'setVisible') {
        setVisible(visible)
        if (data) setName(data.name)
      }
    }
    window.addEventListener('message', onMessage)
    return () => window.removeEventListener('message', onMessage)
  }, [])

  if (!visible) return null
  return <div className="panel">Hello {name}</div>
}

A page that renders nothing when hidden is the usual pattern, since the NUI page itself stays loaded.

UI to Lua: fetch and RegisterNUICallback

For the other direction, the page POSTs to a URL on your resource. GetParentResourceName() is a function FiveM provides to the page and returns the resource name:

js
export async function nuiPost(eventName, data = {}) {
  const resource = window.GetParentResourceName ? window.GetParentResourceName() : 'my_script'
  const resp = await fetch(`https://${resource}/${eventName}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json; charset=UTF-8' },
    body: JSON.stringify(data),
  })
  return resp.json()
}
jsx
<button onClick={() => nuiPost('close')}>Close</button>

In Lua, handle it. The callback must be called, or the request stays pending:

lua
RegisterNUICallback('close', function(data, cb)
    setOpen(false)   -- gives focus back
    cb({ ok = true })
end)

data is the decoded JSON body and cb answers the fetch. Anything you pass to cb comes back as the response.

Develop in the browser

Rebuilding and restarting the resource for every CSS change is slow. Run Vite and work in a normal browser tab:

bash
npm run dev

Outside the game, window.GetParentResourceName does not exist and nobody sends you messages, so mock both:

js
export const isBrowser = !window.invokeNative

// in main.jsx, in dev only
if (isBrowser) {
  setTimeout(() => {
    window.postMessage({ action: 'setVisible', visible: true, data: { name: 'Mic' } }, '*')
  }, 500)
}

The nuiPost function above already falls back to a default resource name. In the browser, the request simply fails, so catch it or skip the call when isBrowser is true. Use a dark or checkered background in dev, since the real game shows the world behind the page.

Iterating inside the game

For a final check in game, build and restart only the resource:

bash
npm run build

then ensure my_script in the server console. With vite build --watch, the files rebuild every time you save, and you only restart the resource.

Common problems

  • Blank page. Missing base: './', or files does not include web/dist/**.
  • Nothing happens when clicking. The RegisterNUICallback name does not match the fetch URL, or cb is never called.
  • Cursor stuck after closing. Missing SetNuiFocus(false, false) on every close path, including a resource restart.
  • Fonts and images missing in game. They are referenced by absolute path, or are not in dist.
  • Message never arrives. The page was not loaded yet, or action names differ between Lua and the listener.

Checklist

Symptom Fix
Blank UI in game, works in browser base: './' in vite.config, then npm run build
Files missing for players files { 'web/dist/**' } in fxmanifest.lua
Button does nothing Match the RegisterNUICallback name and call cb
Cannot reach the game from the page fetch to https://${GetParentResourceName()}/name
Cursor does not come back SetNuiFocus(false, false) when closing
Developing without the game Run vite, mock postMessage and GetParentResourceName

Quick answers

Why is my React NUI a blank page in FiveM?

Usually the build uses absolute asset paths. Set base: './' in vite.config, rebuild, and make sure files in fxmanifest.lua includes the whole web/dist folder.

How does the UI send data back to the game?

With a fetch POST to https://${GetParentResourceName()}/eventName, handled in Lua by RegisterNUICallback('eventName', ...), which must call its callback.

Can I develop the NUI without starting FiveM?

Yes. Run vite in the browser, detect that you are outside the game, and send fake messages with window.postMessage to mock what Lua would send.

Scripts that skip this problem

Mic PhoneA foldable phone that unfolds into a tablet and carries onto a player's real phone.View script β†’Clothing DesignerDesign clothing inside FiveM β€” brush, layers, image import and AI β€” then wear it.View script β†’Arcade MachinesSeven playable arcade games in real cabinets, with leaderboards and bets.View script β†’

Keep reading