> ## Documentation Index
> Fetch the complete documentation index at: https://docs.awokenlabs.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Building an app

> Put your own app on the tablet's home screen: registering it, who can see it, and the React side.

A tablet app is a normal FiveM resource with a small React bundle. The tablet loads that bundle at runtime and mounts it in its own frame, so your resource has **no `ui_page`** and never touches NUI focus. The tablet owns the device, the status bar, the dock and notifications. You own what is inside your app.

<Note>
  This page is for developers. If you only want to send a notification, show the HUD or read a player's group, you do not need an app. See [Exports](/resources/awoken-tablet/exports).
</Note>

## What you need

| Tool | Version |
| - | - |
| Node.js | 20 or newer |
| React and React DOM | `^19.0.0`, matching the tablet exactly |
| Vite | `^6.0.0` |
| `@vitejs/plugin-react` | `^5.0.0` |
| `@originjs/vite-plugin-federation` | `^1.4.1` |

<Warning>
  The React version is not a suggestion. The tablet shares its copy of React with every app. If your app ships a different major, the page ends up with two copies and every hook in your app fails with "Invalid hook call".
</Warning>

Your app also needs two packages from us: `@awoken/tablet-sdk` for hooks, and `@awoken/tablet-design-system` for components that match the tablet. They are not part of the release download. Ask for them through [support](/support) and reference them from your `package.json` as local folders:

```json package.json theme={"dark"}
{
  "dependencies": {
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  },
  "devDependencies": {
    "@awoken/tablet-sdk": "file:./vendor/awoken-tablet-sdk",
    "@awoken/tablet-design-system": "file:./vendor/awoken-tablet-design-system",
    "@originjs/vite-plugin-federation": "^1.4.1",
    "@vitejs/plugin-react": "^5.0.0",
    "typescript": "^5.7.0",
    "vite": "^6.0.0"
  }
}
```

## Quick start

<Steps>
  <Step title="Register the app from your server">
    ```lua server.lua theme={"dark"}
    CreateThread(function()
        Wait(0)   -- one tick, so the tablet's exports are ready

        local remoteEntry = ('https://cfx-nui-my_app/web/dist/remoteEntry.js?v=%s-%d')
            :format(GetResourceMetadata(GetCurrentResourceName(), 'version', 0) or '0', os.time())

        local ok, err = exports.awoken_tablet:RegisterApp({
            id          = 'my_app',
            name        = 'My App',
            icon        = 'truck',          -- a lucide icon name, or a full image URL
            accent      = '#FB923C',
            remoteEntry = remoteEntry,
            moduleName  = './MyApp',        -- must match `exposes` in vite.config.ts
            sdkVersion  = 1,
        })

        if not ok then
            print(('[my_app] tablet registration failed: %s'):format(err))
        end
    end)
    ```

    The `?v=` on the end matters. See [Restarting does not update the UI](#common-pitfalls).
  </Step>

  <Step title="Serve the bundle from your manifest">
    ```lua fxmanifest.lua theme={"dark"}
    dependencies { 'awoken_tablet' }

    server_scripts { 'server.lua' }

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

    No `ui_page`.
  </Step>

  <Step title="Configure Vite as a federation remote">
    ```ts web/vite.config.ts theme={"dark"}
    import { defineConfig } from 'vite';
    import react from '@vitejs/plugin-react';
    import federation from '@originjs/vite-plugin-federation';

    export default defineConfig({
        plugins: [
            react(),
            federation({
                name: 'my_app',
                filename: 'remoteEntry.js',
                exposes: { './MyApp': './src/MyApp.tsx' },
                shared: ['react', 'react-dom', '@awoken/tablet-sdk', '@awoken/tablet-design-system'],
            }),
        ],
        build: {
            target: 'esnext',
            modulePreload: false,
            cssCodeSplit: false,
            assetsDir: '',
        },
        base: './',
    });
    ```

    Keep `assetsDir: ''`. With Vite's default, the bundle looks for its chunks under `assets/assets/` and they fail to load.
  </Step>

  <Step title="Write the component">
    ```tsx web/src/MyApp.tsx theme={"dark"}
    import { useTabletPlayer, useTablet } from '@awoken/tablet-sdk';
    import { Button, Card, SectionHeader } from '@awoken/tablet-design-system';

    export default function MyApp() {
        const player = useTabletPlayer();
        const tablet = useTablet();

        return (
            <div style={{ padding: 36 }}>
                <SectionHeader title="My App" subtitle={`Hi ${player.name}`} />
                <Card style={{ marginTop: 16 }}>
                    <Button onClick={() => tablet.toast({ title: 'Saved', type: 'success' })}>
                        Save
                    </Button>
                </Card>
            </div>
        );
    }
    ```

    The component must be the file's **default export**.
  </Step>

  <Step title="Build and restart">
    Build the web project, restart your resource, and open the tablet. Your tile is on the home screen.
  </Step>
</Steps>

## Registering

`RegisterApp` returns `ok, err`. It fails when a required field is missing (`id`, `name`, `remoteEntry`, `moduleName`), when the `id` is already registered by another resource, or when `sdkVersion` does not match the tablet's.

The tablet drops your app on its own when your resource stops, so you do not need to deregister on shutdown. `DeregisterApp(id)` is only for hiding an app at runtime.

The tablet's SDK version is `1`. An app on a different major is refused and its tile reads "Update". Only raise `sdkVersion` when we announce a new major.

## Who can see the app

Pass a `visibility` list to `RegisterApp`. Every rule must pass. Leave it out and everyone sees the app.

```lua theme={"dark"}
visibility = {
    { type = 'item', item = 'courier_usb' },
    { type = 'job',  job  = 'courier' },
}
```

| Type | Fields | Notes |
| - | - | - |
| `item` | `item`, `count` | The player carries the item. `count` defaults to 1. |
| `sdcard` | `item` | The card is installed inside their tablet. Falls back to an `item` check when [SD cards](/resources/awoken-tablet/sd-cards) are off. |
| `job` | `job`, `grade` | `grade` is a minimum and optional. |
| `gang` | `gang`, `grade` | QBox and QBCore only. Always fails on ESX. |
| `ace` | `ace` | A server ace permission. Best for staff apps. |
| `callback` | `resource`, `name` | Calls `exports[resource][name](src)`, which must return a boolean. |

Rules are re-checked on player load, inventory change, and job or gang change. If something else changed, such as a flag your own script keeps, call:

```lua theme={"dark"}
exports.awoken_tablet:RefreshVisibility(src)
```

<Note>
  Use `sdcard` for any app you want locked behind a physical unlock. It works whichever way the server owner has SD cards set, so you never have to know.
</Note>

## Talking to your server

Register an ox\_lib callback and call it from React.

```lua theme={"dark"}
lib.callback.register('my_app:getJobs', function(src)
    return getJobsFor(src)
end)
```

```tsx theme={"dark"}
const getJobs = useTabletCallback<void, Job[]>('my_app:getJobs');
const jobs = await getJobs();
```

To push something to an open app, use `SendAppEvent`:

```lua theme={"dark"}
exports.awoken_tablet:SendAppEvent(src, 'my_app', 'crewInvited', {
    leaderName  = 'John',
    memberCount = 3,
})
```

```tsx theme={"dark"}
useTabletEvent('crewInvited', (msg) => {
    // msg = { leaderName, memberCount, action, appId }
});
```

Delivery is best effort. A closed tablet drops the message, so your app must rebuild its state from a callback every time it opens. For anything the player must not miss, use `PushNotification`.

<Warning>
  **Never send a vector to the tablet.** Strings, numbers, booleans and tables of those only. A `vector3` anywhere in a payload crashes the player's game outright, with no Lua error to point at the cause.

  `SendAppEvent` checks for this, refuses the payload and prints the offending key. **Callback returns have no such check.** Send coordinates as plain `{ x, y, z }` tables.
</Warning>

## The SDK

```ts theme={"dark"}
import {
    useTabletPlayer,        // { source, identifier, name, job?, gang? }
    useTabletTheme,         // { primaryColor }
    useTabletSettings,      // the player's saved tablet settings
    useTabletApps,          // the apps this player can see
    useTablet,              // close(), openApp(id), toast({ title, type })
    useTabletEvent,         // listen for SendAppEvent
    useTabletCallback,      // call an ox_lib callback
    useTabletSessionState,  // state that survives closing the tablet
    useTabletNotifications, // the player's inbox
    useCurrentGroup,        // { group, incomingInvite, actions }
    registerStrings, t,     // translations
} from '@awoken/tablet-sdk';
```

The design system exports `Button`, `Card`, `FieldRow`, `SectionHeader` and `Select`, plus CSS variables such as `--bg-panel`, `--text-primary` and `--awoken-primary`.

### Translations

```ts theme={"dark"}
registerStrings('en', { 'my_app.greeting': 'Hi {name}' });
registerStrings('fr', { 'my_app.greeting': 'Salut {name}' });

t('my_app.greeting', { name: player.name });
```

The active language follows the tablet's `Config.Locale`. A missing key falls back to English, then to the key itself.

### If your app crashes

Each app runs inside its own error boundary. If your component throws while rendering, the player sees a "could not load" message inside your app's frame and the rest of the tablet keeps working. The error is logged to the F8 console.

Errors inside promises and event handlers are not caught by it. Handle those yourself.

## Common pitfalls

<AccordionGroup>
  <Accordion title="Restarting my resource does not update the UI" icon="rotate">
    The tablet caches a loaded bundle against its URL. Register the same URL twice and it keeps the one it already has.

    Put a token on your `remoteEntry` URL that changes whenever your resource starts, as in the quick start. Version first, then the start time.
  </Accordion>

  <Accordion title="The tile appears but tapping it does nothing" icon="hand-pointer">
    The bundle could not be fetched. Check the `remoteEntry` URL is `https://cfx-nui-<your-resource>/web/dist/remoteEntry.js` with the resource name matching the folder name exactly, and that your manifest's `files` block includes `web/dist/**/*`.
  </Accordion>

  <Accordion title="A chunk 404s with assets/assets in the URL" icon="link-slash">
    Set `assetsDir: ''` in your Vite config.
  </Accordion>

  <Accordion title="Invalid hook call" icon="triangle-exclamation">
    Your app loaded its own copy of React. Pin `react` and `react-dom` to `^19.0.0` and list both in the federation `shared` array.
  </Accordion>

  <Accordion title="The app loads but says there is no default export" icon="file-code">
    The file you `expose` must `export default` your component. Avoid named exports alongside it in that file.
  </Accordion>

  <Accordion title="The player's game closes when my app opens" icon="skull">
    A vector reached the tablet, most often coordinates in a callback return. Convert them to plain `{ x, y, z }` tables before returning.
  </Accordion>

  <Accordion title="Dates crash the app" icon="calendar-xmark">
    oxmysql returns `TIMESTAMP` columns as numbers. Format them in the query so the app always receives a string:

    ```sql theme={"dark"}
    DATE_FORMAT(created_at, '%Y-%m-%dT%H:%i:%sZ')
    ```
  </Accordion>
</AccordionGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.