Skip to content

Guide: from nexus create to a working screen

This guide builds a small resource from nothing: a screen that opens in game, calls the server, and shows the answer. It takes about fifteen minutes and touches every part of the framework. The reference for each part is in format.md (components), bridge.md (the contract and the Lua API) and cli.md (the commands).

  • Node.js 20 or newer.
  • A FiveM server to test on. The Lua side needs nothing but lua54 'yes': no framework and no library. It runs next to ESX, QBCore, Qbox or nothing at all.
  • An editor. For VS Code, the folder editor/vscode of the repository is an extension that highlights .nexus files and adds snippets. Install it with the command “Developer: Install Extension from Location…” and pick that folder.
npm create nexus-ui my_shop
cd my_shop
npm install

npm create nexus-ui runs nexus create from the newest release on npm, and the new resource depends on @nexusstudios/ui by version, so npm update brings later fixes. To work without the registry, from a downloaded file or a clone, see Installing from a release file.

The folder is a complete FiveM resource:

my_shop/
fxmanifest.lua
client/main.lua registers /my_shop, which opens the screen
server/main.lua answers the one call
locales/en.json the strings
web/
contract.ts the one call, declared
mock.ts stands in for the game in a browser
screens/Main.nexus the screen
npm run dev

Open the address it prints. The page is empty apart from a small bar at the bottom: nothing is open yet, exactly as in game. Click main in the bar. The screen opens with the props that web/mock.ts gives it, and its button calls the handler in the same file.

Change a word in web/screens/Main.nexus and save. The screen is mounted again in place, with the props it had. Click log in the bar to see what crosses the bridge.

Everything on screen at this point runs without a game. That is the normal way to work on a UI: in a browser, with the mock answering. The game is needed to test the Lua.

web/screens/Main.nexus has three parts.

---
import { signal, nui, t, NuiError } from 'nexus';
interface Props {
name: string;
}
const name = signal(props.name);
const reply = signal('');
async function send() {
try {
const result = await nui.call('greet', { name: name.value });
reply.value = result.message;
} catch (error) {
reply.value = t('main.refused', error instanceof NuiError ? error.code : String(error));
}
}
---
<screen focus="mouse keyboard" close="escape" size="1920x1080" />
<section class="panel">
<h1>{t('main.title')}</h1>
<input bind:value={name} maxlength="24">
<button on:click={send} disabled={nui.pending('greet')}>{t('main.send')}</button>
{#if reply}
<p class="reply">{reply}</p>
{/if}
</section>
<style>
.panel { /* scoped to this component */ }
</style>
  • The script between the --- lines runs once, when the screen opens. props is what Lua passed to Nexus.open. A signal is a value the template follows: when reply.value changes, the one text node that shows it changes, and nothing else runs.
  • The <screen> tag says how the screen behaves in game: it takes the mouse and the keyboard, Escape closes it, and it is designed at 1920x1080 and scaled to fit any resolution.
  • The template is HTML with {expressions}, blocks such as {#if} and {#each}, and directives such as on:click and bind:value.
  • The style applies to this component only.

Say the screen should also show how many players are online. That is a question for the server, so it is a call. A call is declared once and then used from three places.

Declare it in web/contract.ts:

export default contract({
calls: {
greet: { /* as before */ },
online: {
output: s.object({ players: s.int({ min: 0 }), slots: s.int({ min: 1 }) }),
rate: { limit: 2, per: 5 },
},
},
});

It takes no input, so it has none. rate allows a player two of these calls in any five seconds.

Answer it in server/main.lua:

Nexus.handle('online', function(source)
return { players = #GetPlayers(), slots = GetConvarInt('sv_maxclients', 48) }
end)

Use it in the script of Main.nexus:

const online = signal('');
nui.call('online').then((result) => {
online.value = `${result.players} / ${result.slots}`;
});

and in its template: <p>{online}</p>.

Save, and the editor already knows that result has players and slots: the dev server rewrote web/nexus-contract.d.ts when the contract changed. Write result.player and npm run check reports it at that line of the .nexus file.

Mock it in web/mock.ts, so the browser has an answer too:

calls: {
greet: (input) => ({ message: `Hello ${input.name}.` }),
online: () => ({ players: 12, slots: 48 }),
},

The mock handler is typed by the contract as well. Return { players: '12' } and it is a type error in the editor, and at run time the mock refuses the answer just as the server would in dev mode.

To refuse a call, return a rejection with a code of your choosing:

Nexus.handle('greet', function(source, data)
if isMuted(source) then
return Nexus.reject('muted')
end
return { message = 'Hello ' .. data.name }
end)

In the page the call then rejects with a NuiError whose code is 'muted'.

A refusal can say more than its code. Declare what it carries under errors, and the handler hands it over as the second argument of Nexus.reject:

buy: {
input: s.object({ item: s.string({ max: 32 }) }),
output: s.object({ balance: s.int() }),
errors: { not_enough_money: s.object({ missing: s.int({ min: 1 }) }) },
},
return Nexus.reject('not_enough_money', { missing = price - balance })

The page reads it from error.details. In dev mode the server checks what the handler passes against the schema, as it checks an answer. See Refusals.

Screens are opened by client Lua, with whatever props they need:

RegisterCommand('my_shop', function()
Nexus.open('main', { name = GetPlayerName(PlayerId()) })
end, false)

The name is the file name with its first letter in lower case: web/screens/VehicleShop.nexus is vehicleShop.

What a screen is opened with can be declared in the contract, next to the calls:

screens: {
main: s.object({ name: s.string({ max: 64 }) }),
},

props in Main.nexus then has that type without an interface Props, npm run check reports a screen that reads a prop the contract does not have, and in dev mode Nexus.open raises an error when Lua passes something else. A screen the contract does not mention keeps its own interface Props.

Nothing in Lua mentions focus. The <screen> tag declares it, and the client runtime gives and releases it as screens open and close. Closing happens in one of three ways, and Lua does not have to care which:

  • the player presses Escape (when the tag says close="escape");
  • the page calls nui.close();
  • Lua calls Nexus.close('main').

If something in the game has to follow the screen, a camera for example, tie it to the screen instead of to the code that opens it:

Nexus.onOpen('main', function(props)
startCamera()
end)
Nexus.onClose('main', function()
stopCamera()
end)

onClose also runs when the resource stops while the screen is open.

For something the page asks of the client that is not a question of authority, such as showing a preview, declare a client message in the contract, send it with nui.client('preview', data) and handle it with Nexus.on('preview', function(data) end).

A HUD is a screen on the hud layer: it takes no focus and stays under the other screens. Its data is a state: an object that Lua patches and the page reads.

web/contract.ts
state: {
hud: s.object({ speed: s.int({ min: 0 }), fuel: s.int({ min: 0, max: 100 }) }),
},
---
import { nui } from 'nexus';
const hud = nui.state('hud');
---
<screen layer="hud" size="1920x1080" />
<div class="speed">{hud.speed}</div>
<div class="fuel" style:width="{hud.fuel}%"></div>
-- client/main.lua
CreateThread(function()
Nexus.open('hud')
while true do
local vehicle = GetVehiclePedIsIn(PlayerPedId(), false)
if vehicle ~= 0 then
Nexus.set('hud', {
speed = math.floor(GetEntitySpeed(vehicle) * 3.6),
fuel = math.floor(math.min(GetVehicleFuelLevel(vehicle), 100.0)),
})
end
Wait(100)
end
end)

Nexus.set compares with what it sent before and sends only the keys that changed, so this loop costs no message while the car stands still. A table is compared by its content, so setting the same list again sends nothing either. In the page, only the node that shows a changed key updates.

Nexus.unset('hud', 'fuel') removes a key. The server can patch a state too, for one player or for everyone, which saves an event of your own:

-- server/main.lua
Nexus.set(source, 'hud', { fuel = 100 })
Nexus.set(-1, 'hud', { fuel = 100 })

In the mock, a setup function plays the part of that loop. The garage example in examples/garage does exactly this.

Strings live in locales/<language>.json, as flat keys:

{
"main.title": "Hello from my_shop",
"main.refused": "The server refused the call: %s"
}

Client Lua loads one file and hands it to the page:

local language = GetConvar('my_shop_locale', 'en')
Nexus.locale(json.decode(LoadResourceFile(GetCurrentResourceName(), ('locales/%s.json'):format(language))))

In a component, t('main.title') looks a string up and t('main.refused', code) fills its %s. A missing key shows as the key itself. The mock imports the same file, so the browser shows the same strings. Lua can read the table for its own messages, which keeps a translation in one place.

Remember files { 'locales/*.json' } in fxmanifest.lua: the client can only load files the manifest ships.

A screen can be an app in LB Phone or LB Tablet instead of a screen of its own. It is the same kind of file, with a surface in its tag:

---
import { nui, t } from 'nexus';
const hud = nui.state('hud');
---
<screen surface="phone" />
<main class="app">
<h1>{t('app.title')}</h1>
<p>{hud.fuel}%</p>
</main>
<style>
.app { height: 100%; padding: 4rem 1.25rem 1.5rem; background: #09090b; color: #f5f5f5; }
</style>

Client Lua registers it once, at the top level of a script:

Nexus.app('phone', {
name = 'My shop',
description = 'Order from anywhere',
icon = 'web/dist/app-icon.png',
defaultApp = true,
})

That is all of it. The runtime adds the app when LB Phone is running, adds it again when LB Phone restarts, and removes it when your resource stops. On a server without LB Phone the line does nothing, so one resource serves both kinds of server. 'tablet' and <screen surface="tablet" /> do the same for LB Tablet.

What changes for an app:

  • LB opens and closes it, not Lua. Nexus.open refuses its name, and focus, close, size and layer have no meaning in its tag. Nexus.onOpen and Nexus.onClose still run.
  • Calls, pushes, state and strings work as in any screen. A push and a state reach the app and the main page alike.
  • It has no props. What it shows comes from calls and state.
  • LB sets the font size of the frame to fit the device, so sizes in rem scale with the phone. Leave room at the top for the status bar.
  • The icon is a file the resource ships. Put it in web/public and it is built to web/dist.

With an app in the project, the bar of npm run dev gains a phone app or tablet app button. It shows the app in a frame of the device’s size, next to the screens, fed by the same mock. The reference is Surfaces.

A screen can also be drawn in the game world: on the monitor of a desk, a kiosk, a time clock. It is again the same kind of file. Its tag says world, and the size of the browser that draws it:

---
import { signal } from 'nexus';
const query = signal('');
---
<screen surface="world" size="1024x576" />
<main class="kiosk">
<h1>{props.title}</h1>
<input bind:value={query} placeholder="Search the menu">
<p>You typed: {query}</p>
</main>
<style>
.kiosk { height: 100%; padding: 40px; background: #09090b; color: #f5f5f5; font-size: 32px; }
input { font: inherit; padding: 12px 16px; border: 2px solid #3f3f46; background: #18181b; color: inherit; }
input:focus, input[data-nexus-focus] { border-color: #c8ff3d; }
</style>

Save it as web/screens/Kiosk.nexus. Client Lua draws it on a texture of the prop, once the prop exists, and takes it away again when the player has left:

local display
local function showKiosk()
local problem
display, problem = Nexus.world('kiosk', {
txd = 'my_kiosk', -- the name of the prop's model
texture = 'my_kiosk_screen', -- the texture its screen shows
props = { title = 'Orders' },
})
if not display then print('The kiosk stays dark: ' .. problem) end
end
local function hideKiosk()
if display then display:destroy() end
display = nil
end

The prop needs a texture of its own for its screen, which is what texture names. What that texture holds in the model file is what players see while there is no display, and what every other player sees.

To let the player use it, from a target option or a key near the prop:

Nexus.operate(display, {
entity = prop,
camera = { offset = vec3(0.0, -0.7, 0.4), fov = 40.0 },
onExit = function() end,
})

The camera moves in front of the prop, the mouse moves a cursor on its screen, and what the player types goes into the field that was clicked. Escape gives the game back.

What changes for a world screen:

  • Lua creates a display of it instead of opening it. Nexus.open refuses its name.
  • It is one browser per display, so a resource has four at a time unless the server raises nexus_world_limit, and Nexus.world returns nil and a reason instead of a display when there can be none. Fall back to an ordinary screen then.
  • The browser never has the game’s focus. A field that was clicked takes what is typed, yet :focus does not match it: the style above also names [data-nexus-focus], which the runtime sets instead.
  • Size the type for a prop seen from a few steps away, not for a monitor.

In web/mock.ts, worlds: { kiosk: { props: { title: 'Orders' } } } gives the screen its props under npm run dev, where the bar gains a kiosk button that shows it in a frame of its size. The reference is World screens, and examples/world is a resource that tests every part of this in game.

npm run build

writes the page to web/dist and the Lua bridge to nexus/, and checks that fxmanifest.lua loads both. If a line is missing, it prints the line.

Put the folder in your server’s resources and add to server.cfg:

ensure my_shop
setr nexus_dev 1

The second line is for a development server only. It makes the bridge check what Lua sends against the contract and say exactly what is wrong. See Dev mode.

In game, /my_shop opens the screen. A server only needs fxmanifest.lua, your Lua, locales, nexus/ and web/dist. It does not need node_modules or the sources.

nexus/ and web/dist are build output, and the .gitignore of a new resource leaves both out of git. A release has to contain them all the same: a server owner who downloads the resource has no Node.js to build it with. Run npm run build and pack the folder with those two inside, or attach the built folder to the release instead of relying on the source archive.

To see changes in game without building each time:

npm run dev -- --game

This points the resource’s ui_page at the dev server until you stop the command. Run refresh and ensure my_shop once in the server console, and from then on saving a .nexus file updates the screen in game. The page talks to the real Lua here, not to the mock.

A change to the contract or to a <screen> tag changes the Lua bridge. The terminal says so, and ensure my_shop loads it.

A display of a world screen loads its page from the dev server as well, so the screen on the prop updates when its file is saved.

npm run check

type-checks the scripts and the templates against the contract and reports CSS and JavaScript that FiveM’s browser, Chromium 103, cannot run. It exits with an error code when it finds something, so it can run in CI. See nexus check.

14. Installing from a release file, or a clone

Section titled “14. Installing from a release file, or a clone”

Every release on GitHub has the package attached as one file, nexusstudios-ui-<version>.tgz: the same file npm serves. It is there for a machine without access to the registry, and for a build of your own. Download it, then:

npx --package ./nexusstudios-ui-0.3.0.tgz nexus create my_shop
cd my_shop
npm install --save-dev ../nexusstudios-ui-0.3.0.tgz

The last line installs everything the resource needs and points its @nexusstudios/ui dependency at the file, so keep the file where it is, or give the path of wherever you keep it.

From a clone, build that same file yourself:

git clone https://github.com/NexusStudiosCfx/nexus-ui
cd nexus-ui
npm install
npm run build
npm pack --pack-destination releases

and install releases/nexusstudios-ui-<version>.tgz as above.

Install the packed file, not the folder of the clone. npm links a folder instead of copying it, so npm install ../nexus-ui or npm link puts the whole repository, its examples and their node_modules included, inside your resource, and a resource that lives in the clone ends up containing itself. The packed file has only what the package ships.

npm test runs the test suite. The tests that drive a real Chromium 103 are skipped unless the environment variable NEXUS_CHROMIUM_103 holds the path of its executable.

The example resource is in examples/garage: a garage menu with a list, a search, a purchase that the server validates and can refuse, a vehicle HUD, and the same garage as an app in LB Phone. examples/world is a second, small one: a screen on a prop, and /worldtest to prove in game that every part of it works. Both depend on the package from npm like any other resource:

cd examples/garage
npm install
npm run build

To build it against the file you packed from the clone instead, install that over it:

npm install --no-save ../../releases/nexusstudios-ui-0.3.0.tgz

nexus/contract.lua must load before nexus/server.lua in the server console. The manifest loads the bridge in the wrong order. Run npm run build: it prints the lines to use.

Nexus.open: there is no screen 'shop'. The name is the file name in web/screens with a lower-case first letter. After adding a screen, build again: Lua learns the screens from nexus/screens.lua.

A call fails with rejected. The server console says why: the handler raised an error, the call has no handler, or in dev mode the answer did not match the contract.

A call fails with invalid. error.message names the key and what was expected. The page sent something the contract does not allow.

The screen works in the browser but not in game. Run npm run check. The usual cause is CSS or JavaScript that Chromium 103 does not have. If the check is clean, open the developer tools of the page in game (nui_devtools in the F8 console) and look at its console.

Cannot find module 'nexus' in the editor or in nexus check. tsconfig.json needs the two paths entries that the template has, which map nexus and nexus/contract to the package.

The cursor stays on screen after a resource restart. The runtime releases focus when its resource stops. If another resource took focus, that one has to release it.

npm install stops with ERESOLVE after adding a linter. A new resource pins TypeScript to ~6.0.0 because typescript-eslint 8 accepts nothing newer. If you raised it, lower it again or leave the linter out. nexus check works with either.

The prop shows its own screen, not the world screen. Nexus.world returned a display, and the texture was not replaced: FiveM only replaces a texture it finds at that moment. Check that the prop exists before the display is created, that txd is the name of its model and texture the name of the texture in it, both spelt as in the model file. /worldtest <model> <txd> <texture> in examples/world tries a model in isolation.

The app is not in the phone. The client console says why when LB refuses it, with the reason LB gave. Otherwise check that Nexus.app runs at the top level of a client script, that the resource was built after the surface was added, and that the player has the app installed if it is not a defaultApp.