Skip to content

Creating a Plugin

Plugins are standalone FiveM resources that extend EasyAdmin's UI. They register at runtime and provide declarative schema trees that EasyAdmin renders using its built-in components.

Prerequisites

  • Your resource must start after EasyAdmin. Add it as a dependency:
1
2
3
4
5
6
7
8
-- fxmanifest.lua (in YOUR resource)
fx_version 'cerulean'
game 'gta5' -- RedM: use 'rdr3'. Both: games { 'gta5', 'rdr3' }

dependencies { 'EasyAdmin' }

client_scripts { 'client.lua' }
server_scripts { 'server.lua' }

Registration

From your resource's server script, call:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
-- server.lua
exports.EasyAdmin:RegisterPlugin({
  id = 'my-plugin',
  name = 'My Plugin',
  version = '1.0.0',
  icon = 'box',

  permissions = { 'plugin.my-plugin', 'plugin.my-plugin.advanced' },

  navItems = {
    { id = 'plugin:my-plugin', label = 'My Plugin', icon = 'box' },
  },
  pages = {
    { view = 'plugin:my-plugin', renderAction = 'renderPage' },
  },
})
Field Required Description
id Yes Unique plugin id. The only field EasyAdmin validates
name Yes Display name
version Yes Version string
author No Author name
description No Short description
icon No Default icon for nav items
permission No Hides everything if admin lacks this permission
permissions No Permission keys this plugin uses. Every gate must be listed here
navItems No Sidebar entries
pages No Full-page views
playerDetailTabs No Tabs in the player detail page
dashboardWidgets No Cards on the dashboard

name and version must be set for the UI to display your plugin, even though EasyAdmin only rejects a registration without an id.

Registering the same id again replaces the previous registration. When your resource stops, EasyAdmin removes the plugin and its permissions automatically.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
navItems = {
  { id = 'plugin:my-plugin', label = 'My Plugin', icon = 'box' },
  -- Multi-page:
  { id = 'plugin:my-plugin:settings', label = 'Settings', icon = 'settings' },
  -- Permission-gated, with a badge:
  { id = 'plugin:my-plugin:queue', label = 'Queue', icon = 'list',
    permission = 'plugin.my-plugin.advanced', badge = 3 },
  -- Placeholder that cannot be opened yet:
  { id = 'plugin:my-plugin:soon', label = 'Coming Soon', icon = 'clock', disabled = true },
},

id is used as the target view unless you set view explicitly, and that target must match a page's view.

permission hides the nav item from admins without that permission, badge shows a small counter, and disabled = true greys the item out.

Categories (dropdown nav items)

Use children to nest pages under a category:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
navItems = {
  {
    id = 'plugin:my-plugin:tools',
    label = 'Tools',
    icon = 'layers',
    children = {
      { id = 'plugin:my-plugin:stats', label = 'Stats', icon = 'chart-bar' },
      { id = 'plugin:my-plugin:actions', label = 'Actions', icon = 'zap' },
    },
  },
},

Children can also be permission-gated individually.

Pages

1
2
3
4
pages = {
  { view = 'plugin:my-plugin', renderAction = 'renderPage' },
  { view = 'plugin:my-plugin:settings', renderAction = 'renderSettings' },
},

renderAction is the action name the NUI calls to fetch the schema tree.

Player detail tabs

1
2
3
4
playerDetailTabs = {
  { id = 'notes', label = 'Notes', icon = 'book-open', renderAction = 'renderTab' },
  { id = 'admin', label = 'Admin', permission = 'plugin.my-plugin.advanced', renderAction = 'renderAdminTab' },
},

Every permission used as a gate here must also be listed in the plugin's permissions array — see Permissions.

Dashboard widgets

1
2
3
dashboardWidgets = {
  { id = 'status', renderAction = 'renderWidget', order = 150 },
},

order controls sort position (lower = first, default 100).

Render Handlers

FiveM exports cannot pass functions between resources, so handlers are registered via events. Each renderAction maps to an event:

1
2
3
4
5
6
7
-- Client handler for renderPage action
AddEventHandler('EasyAdmin:Plugin:action:my-plugin:renderPage', function(data, cb)
  cb({
    { type = 'heading', text = 'My Plugin', level = 2 },
    { type = 'text', text = 'Hello from Lua!', variant = 'muted' },
  })
end)

The event name format is: EasyAdmin:Plugin:action:<pluginId>:<actionName>

The handler receives data.context:

Field Value
target 'page', 'widget', or 'player-tab'
playerId Player server ID (only for player tabs)

Handlers must call cb within about 500 ms — this applies to client and server handlers alike. EasyAdmin drops the response after that, so cache or precompute slow data instead of blocking inside the handler.

See the schema component reference for all available components.

Button Actions

Buttons in the schema have an action field. When clicked, the NUI routes to the matching event handler:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
-- In your schema:
-- { type = 'button', label = 'Refresh', action = 'refresh', icon = 'refresh' }

-- Handler:
AddEventHandler('EasyAdmin:Plugin:action:my-plugin:refresh', function(data, cb)
  -- Return a schema array → replaces the page:
  cb({
    { type = 'heading', text = 'My Plugin', level = 2 },
    { type = 'badge', text = 'Refreshed!', variant = 'online' },
  })
end)

Two return patterns:

cb(...) returns NUI behaviour
Schema array ({ { type = ... } }) or { schema = { ... } } Replaces the current page with the new schema
Anything else ({ ok = true }) Re-fetches the original renderAction

An empty array counts as "nothing to render" and triggers a re-fetch, so return at least one node to replace the page.

Server-side Actions

Add server = true to a button to route it to a server handler:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
-- In schema:
-- { type = 'button', label = 'Get Count', action = 'getCount', server = true }

-- Server handler:
AddEventHandler('EasyAdmin:Plugin:serverAction:my-plugin:getCount', function(source, data, cb)
  if not exports.EasyAdmin:DoesPlayerHavePermission(source, 'plugin.my-plugin.advanced') then
    return cb({ ok = false, error = 'permission denied' })
  end
  cb({ ok = true, count = #GetPlayers() })
end)

The event name format is: EasyAdmin:Plugin:serverAction:<pluginId>:<actionName>

Always permission-guard server handlers. The bridge does not check permissions automatically.

Permissions

Declare every permission your plugin uses in the RegisterPlugin config. EasyAdmin registers them server-side, so DoesPlayerHavePermission() accepts them and the menu can gate your contributions on them:

1
2
3
4
5
6
7
8
exports.EasyAdmin:RegisterPlugin({
  id = 'my-plugin',
  permissions = {
    'plugin.my-plugin',
    'plugin.my-plugin.advanced',
  },
  -- ...
})

Every permission used in a permission field — including the top-level plugin gate — must also be listed here. A gate on an undeclared permission hides that contribution from every admin, with no warning.

Grant the ACE to admins who should see the plugin:

1
2
add_ace group.admin easyadmin.plugin.my-plugin allow
add_ace group.admin easyadmin.plugin.my-plugin.advanced allow

Gate the entire plugin

The top-level permission field hides all contributions when the admin lacks that permission:

1
2
3
4
5
exports.EasyAdmin:RegisterPlugin({
  id = 'my-plugin',
  permission = 'plugin.my-plugin',
  -- ...
})

Gate individual nav items

1
2
3
4
navItems = {
  { id = 'plugin:my-plugin', label = 'My Plugin', icon = 'box' },
  { id = 'plugin:my-plugin:admin', label = 'Admin', permission = 'plugin.my-plugin.advanced' },
},

Gate individual player tabs

1
2
3
playerDetailTabs = {
  { id = 'admin', label = 'Admin', permission = 'plugin.my-plugin.advanced', renderAction = 'renderAdmin' },
},

Refreshing Content

A view re-fetches by itself in three cases: the admin opens it (or switches away and back), one of its button handlers returns a non-schema result, or a handler returns a schema array, which replaces the view instead.

When data arrives later — after the handler already answered — ask EasyAdmin to re-run one of your views. Only that view re-fetches, and it keeps showing its current content until the new schema arrives, so there is no flash:

1
2
3
4
5
6
-- client: keep the result, then refresh the tab it belongs to
RegisterNetEvent('my-plugin:data')
AddEventHandler('my-plugin:data', function(result)
  cache = result
  exports.EasyAdmin:RefreshPlugin('my-plugin', 'tab', 'info', result.playerId)
end)

The arguments are (pluginId, target?, id?, playerId?), where target is 'page', 'widget' or 'tab' and id is the page view, widget id or tab id. Omit them to refresh every view of your plugin. From the server, use TriggerClientEvent('EasyAdmin:Plugin:refresh', source, 'my-plugin', 'tab', 'info', playerId) after handing that client its data.

A plugin can only refresh its own views: the export checks the calling resource against your registration. See Refreshing an open view for the full addressing rules.

Full Example

A complete runnable version of this example — with gated tabs, server actions and form modals — lives in the examples/ea-plugin-demo resource in the EasyAdmin repository.

server.lua

Register the plugin (server-side only — the server is the source of truth and broadcasts to all clients). Permissions are declared in the config:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
exports.EasyAdmin:RegisterPlugin({
  id = 'my-plugin',
  name = 'My Plugin',
  version = '1.0.0',
  icon = 'box',

  permissions = {
    'plugin.my-plugin',
    'plugin.my-plugin.advanced',
  },

  navItems = {
    { id = 'plugin:my-plugin', label = 'My Plugin', icon = 'box' },
  },
  pages = {
    { view = 'plugin:my-plugin', renderAction = 'renderPage' },
  },
  dashboardWidgets = {
    { id = 'status', renderAction = 'renderWidget', order = 150 },
  },
})

-- Server-side handler (reached by button with server = true)
AddEventHandler('EasyAdmin:Plugin:serverAction:my-plugin:getServerData', function(source, data, cb)
  if not exports.EasyAdmin:DoesPlayerHavePermission(source, 'plugin.my-plugin.advanced') then
    return cb({ ok = false, error = 'Requires permission: plugin.my-plugin.advanced' })
  end

  cb({
    ok = true,
    playerCount = #GetPlayers(),
    maxPlayers = GetConvarInt('sv_maxclients', 32),
  })
end)

client.lua

Client scripts only register event handlers for render actions and button clicks. The plugin config is already known to the client via the server broadcast:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
-- Page render handler
AddEventHandler('EasyAdmin:Plugin:action:my-plugin:renderPage', function(data, cb)
  local players = GetActivePlayers()
  local fps = 0
  local ft = GetFrameTime()
  if ft and ft > 0 then fps = math.floor(1.0 / ft) end

  cb({
    { type = 'heading', text = 'My Plugin', level = 2 },
    {
      type = 'row', gap = 3, children = {
        { type = 'stat-card', label = 'Players', value = tostring(#players),
          icon = 'users', iconColor = 'var(--accent-green)', bgColor = 'var(--bg-green)' },
        { type = 'stat-card', label = 'FPS', value = tostring(fps),
          icon = 'gauge', iconColor = 'var(--accent-orange)', bgColor = 'var(--bg-orange)' },
      },
    },
    {
      type = 'card', children = {
        { type = 'heading', text = 'Actions', level = 4 },
        {
          type = 'row', gap = 2, children = {
            { type = 'button', label = 'Re-fetch', action = 'refetchPage',
              icon = 'refresh', variant = 'primary', size = 'sm' },
            { type = 'button', label = 'Get Server Data', action = 'getServerData',
              server = true, icon = 'server', variant = 'danger', size = 'sm' },
          },
        },
      },
    },
  })
end)

-- Dashboard widget
AddEventHandler('EasyAdmin:Plugin:action:my-plugin:renderWidget', function(data, cb)
  local players = GetActivePlayers()
  cb({
    { type = 'card', children = {
        { type = 'row', gap = 2, children = {
            { type = 'icon', name = 'users', size = 'md' },
            { type = 'col', gap = 0, children = {
                { type = 'text', text = 'Players Online', variant = 'small' },
                { type = 'text', text = tostring(#players), variant = 'muted' },
              }},
          }},
      }},
  })
end)

-- Re-fetch action (returns non-schema → re-fetches renderPage)
AddEventHandler('EasyAdmin:Plugin:action:my-plugin:refetchPage', function(data, cb)
  cb({ ok = true })
end)