Skip to content

Building extensions

This page covers building a Nexus extension from project layout through every Elixir and JavaScript API surface. The authoritative reference is the EXTENSION_GUIDE.md included in the Nexus source repository — this page summarises it for practical use.


After scaffolding, an extension directory looks like this:

my-extension/
├── manifest.json
├── mix.exs
├── lib/
│ └── my_extension.ex
├── priv/
│ └── static/
│ ├── my-extension.js
│ ├── logo.webp (200×200, optional)
│ └── banner.webp (800×400, optional)
└── README.md
Source pathInstalled location
manifest.jsonStored in the extensions.manifest DB column
lib/**/*.exCompiled into the BEAM VM (no on-disk artifact preserved)
priv/static/<slug>.js/app/uploads/extensions/<slug>/assets/<slug>.js
priv/static/<filename>/app/uploads/extensions/<slug>/assets/<filename>

Bundled assets are served at /ext/<slug>/assets/<filename>. The logo and banner are served at /ext/<slug>/assets/logo.webp and /ext/<slug>/assets/banner.webp.


Every extension module starts with:

defmodule MyExtension do
use Nexus.Extensions.Behaviour
end

use Nexus.Extensions.Behaviour provides no-op defaults for every callback so the module compiles with zero overrides. Override only the callbacks matching surfaces you declared in your manifest.

Every callback that takes a settings argument receives the extension’s current settings map. Keys are always strings, not atoms:

# Correct
settings["enable_debug_log"]
# Wrong — returns nil
settings[:enable_debug_log]

Use Map.get(settings, "key", default) when the field’s default matters at read time.

Called when a declared hook event fires. Runs in a supervised Task — return value is ignored, crashes are caught and logged. Handlers for the same event run sequentially in priority order (lower numbers first). Push heavy work to Oban jobs rather than doing it inline.

@impl true
def handle_event("post_created", %{"user_id" => user_id, "post_id" => post_id}, settings) do
# do work
:ok
end
# Catch-all required when hooks are declared
def handle_event(_event, _payload, _settings), do: :ok

Hook event payloads:

EventPayload keysNotes
post_createduser_id, post_idActor is the post creator
post_updateduser_id, post_idActor is the editor (may be a moderator)
post_deleteduser_id, post_idPost is already removed from the DB when this fires
reply_createduser_id, reply_id, post_idpost_id is the parent post
reply_deleteduser_id, reply_id, post_idClean up linked rows here
reaction_addeduser_id, emoji, post_id, reply_idExactly one of post_id/reply_id is non-nil
reaction_removeduser_id, emoji, post_id, reply_idMirror of reaction_added
report_createduser_id, report_idActor is the reporter
report_resolveduser_id, report_id, statusActor is the moderator; status is "reviewed", "dismissed", or "actioned"
user_registereduser_iduser_id IS the new user
user_loginuser_iduser_id IS the user logging in

All payload values are JSON-serializable (strings, numbers, booleans, nil, lists, maps). No structs, no DateTime, no PIDs.

These are discrete-event callbacks — they do not run on Nexus restarts.

@impl true
def on_install(settings) do
# Runs once on first install, after migrations have run
:ok
end
@impl true
def on_update(from_version, to_version) do
# Runs on update, after new migrations have run
:ok
end
@impl true
def on_uninstall do
# Runs before migrations roll back
# Use for cleanup: deleting external files, revoking tokens
:ok
end

on_install/1 returns :ok or {:ok, _} for success, {:error, reason} to flag the extension as install_failed (it remains loaded but marked). on_update/2 and on_uninstall/0 behave similarly. If on_uninstall/0 raises, the error is captured as a warning but the uninstall continues.

Returns a list of Ecto migration modules to run at install time and roll back at uninstall:

@impl true
def migrations do
[
MyExtension.Migrations.V1CreateNotes,
MyExtension.Migrations.V2AddIndex,
]
end

Use simple sequential names — V1, V2, V3. Nexus derives a collision-free version integer by hashing your extension’s slug together with the sequence number, so two extensions that both have a V1 migration will never collide in schema_migrations. No date prefixes, no awareness of Nexus core’s migration range required.

Prefix table names with your slug to avoid naming conflicts at the database level: my_extension_notes, not notes.

Migrations replay on every Nexus boot — already-applied versions are safely skipped by Ecto.

Returns Elixir plug routes for your extension’s API. These are distinct from manifest routes (which are SPA page paths):

@impl true
def routes do
[{"/", MyExtension.ApiRouter, []}]
end

The Nexus router intercepts all requests to /ext/<slug>/api/*path and strips the /api/ prefix before passing the request to your plug. For a request to /ext/my-extension/api/status, your plug receives path_info: ["status"] and request_path: "/status".

Define routes in your Plug.Router without the /api/ prefix — it is already consumed:

defmodule MyExtension.ApiRouter do
use Plug.Router
plug :match
plug :dispatch
get "/status" do
# called by fetch(`/ext/${SLUG}/api/status`)
send_resp(conn, 200, Jason.encode!(%{ok: true}))
end
match _ do
send_resp(conn, 404, ~s({"error":"not found"}))
end
end

The prefix string in routes/0 is stripped from the path before your plug receives the conn. Use "/" to forward everything, or a sub-prefix to namespace — {"/v2", MyRouter, []} matches /ext/slug/api/v2/... and your router then sees paths without the /v2 prefix.

conn.assigns.current_user is set if a valid JWT was sent — authentication is not enforced automatically. Use Nexus.Extensions.Permissions.check/3 for access control.

Returns child specs started under a dedicated supervisor (nexus_ext_sup_<slug>). A crashing child only restarts within your extension’s supervisor — Nexus and other extensions are unaffected.

For Oban jobs, use the :extensions queue and namespace worker modules under your extension’s root module (e.g. MyExtension.Workers.SomethingWorker). Workers outside the namespace survive uninstall and crash on next execution.

Called when building digest emails for each section declared in your manifest. Return a structured map:

def handle_digest_section("my_section", period, settings) do
%{
title: "My section — #{period.period_label}",
layout: "list",
items: [
%{label: "Item one", sublabel: "Detail", value: "42"}
],
cta: %{label: "See all", url: "/ext/my-extension"}
}
end
def handle_digest_section(_key, _period, _settings), do: %{items: []}

Five layouts are available: list, leaderboard, stat_bars, pill_grid, card. An empty items list silently drops the section from the email. For sections needing a fully custom layout, return %{"_rendered_html" => html_string} instead.

A 4-arity form handle_digest_section/4 receives a branding map with the email’s color palette — branding.accent is the accent color hex string.

Called when a user submits a post or reply with an attachment your extension’s toolbar button queued via attach():

@impl true
def persist_attachment("post", post_id, %{"kind" => "my_note", "data" => %{"text" => text}}) do
# persist to your own tables
:ok
end
def persist_attachment(_entity, _entity_id, _attachment), do: :ok

Runs asynchronously after the post is committed. Best-effort — if it raises, the error is logged and dropped. The 10 KB per-attachment cap is enforced before this callback is called. Subscribe to post_deleted/reply_deleted hooks to clean up linked rows on entity deletion.

Use Nexus.Repo directly. Reference Nexus’s own tables by string name, not by aliasing internal schema modules:

# Stable — reference by string name
from(u in "users", where: u.id == ^user_id, select: u.username) |> Nexus.Repo.one()
# Not stable — internal modules can change
alias Nexus.Accounts.User # don't do this

Use Nexus.Extensions.Storage for any files your extension writes at runtime. Never construct paths manually:

alias Nexus.Extensions.Storage
Storage.ensure_dir("my-extension", "exports")
abs_path = Storage.path("my-extension", "exports/report.pdf")
File.write!(abs_path, bytes)
url = Storage.url("my-extension", "exports/report.pdf")
# => "/uploads/extensions/my-extension/exports/report.pdf"

Use Nexus.Extensions.Permissions.check/3 to enforce permission gates declared in your manifest:

case Nexus.Extensions.Permissions.check("my-extension", "can_view_gallery", conn.assigns[:current_user]) do
:ok -> # proceed
:error -> conn |> put_status(403) |> json(%{error: "Access denied"})
end

The check resolves against the admin’s saved gate configuration, then the manifest default, then "member" if no default was declared.

Gates are group-aware — admins can configure any permission key to grant access to specific Groups in addition to a role tier. The check function evaluates both conditions automatically: it returns :ok if either the user’s role meets the required tier or the user belongs to any of the configured groups. Your code calls check/3 identically regardless of whether the admin has added group conditions.

Passing nil as the user checks the everyone tier against site-wide guest browsing settings — if guest browsing is off, everyone fails for nil users. Groups never grant access to nil users.


Your bundle is a plain JS file (no build step required). It’s injected into every Nexus page as a <script> tag before React mounts. Every register* call cross-checks itself against your manifest and logs a warning if you register something not declared.

(function() {
"use strict";
const NE = window.NexusExtensions;
const SLUG = "my-extension";
// define components and call NE.register*
})();

Wrap code in an IIFE to avoid polluting the global scope. Use window.React and window.ReactDOM — Nexus’s instances. Don’t ship your own copy of React.

NE.registerRoute(SLUG, "/", HomePage, { title: "Home" });
NE.registerRoute(SLUG, "/games/:slug", GameDetailPage, { title: "Game details" });

Path is relative to your namespace — do not include /ext/. The component receives URL params as props plus currentUser. Must match a path declared in manifest.routes.

NE.registerSlot({ slug: SLUG, slot: "post_footer", component: PostFooter, priority: 50 });

Available slots and their props:

SlotWhere it rendersProps
post_footerBelow post body on /post/:id, above the reply thread{ post_id }
profile_sidebarLeft rail of /profile/:username{ username, current_user }
compose_attachmentsBelow the post body in the composer, above the footer bar{ attachments, set_attachments }

Components receive only the declared props — nothing else is passed.

The compose_attachments slot is specifically for displaying and managing items your extension has attached to an in-flight post via attach(). It renders only in the post composer (not the reply composer), and only when the user is actively composing.

function MyAttachmentsPanel({ attachments, set_attachments }) {
// Filter to only your extension's attachments
const mine = attachments.filter(a => a.kind === "my_note");
if (!mine.length) return null;
function remove(index) {
set_attachments(prev => prev.filter((_, i) => i !== index));
}
return React.createElement("div", { className: "my-attachments" },
mine.map((a, i) =>
React.createElement("div", { key: i },
a.data.text,
React.createElement("button", { onClick: () => remove(i) }, "Remove")
)
)
);
}
NE.registerSlot({ slug: SLUG, slot: "compose_attachments", component: MyAttachmentsPanel });

attachments is the full array of all queued attachments — filter to your own kinds and ignore the rest. set_attachments lets you mutate the list — call it with an updater function to remove an attachment the user wants to discard before posting.

NE.registerAdminPanel(SLUG, { label: "My Extension", icon: "fa-cog", component: MyPanel });

The component receives no props. Use window.NexusExtensionTemplates for the two provided templates:

  • SimpleSettingsPanel — auto-handles settings fetch, dirty state, and save. Pass a slug and fields array.
  • TabbedPanel — tab chrome with each tab’s render function. Nest SimpleSettingsPanel inside settings tabs.

If you use SimpleSettingsPanel to render fields that are also in settings_schema, remove those keys from settings_schema — otherwise the host’s auto-rendered fallback form renders them a second time.

NE.registerExploreItem({ slug: SLUG, path: "/", label: "My Extension", icon: "fa-puzzle-piece", authOnly: false, priority: 50 });

Path defaults to "/". The target must correspond to a registered route. The manifest’s explore field declares one entry; additional entries can be registered from JS.

NE.registerRightWidget({ slug: SLUG, id: "my-widget", label: "My Widget", component: MyWidget, scope: "extension" });

Component receives { currentUser, pageProps }. Scope options: "extension" (pages under your slug), "global", {"path": "/x"}, {"corePages": ["feed", "post", "profile", ...]}.

Core page names for corePages: feed, post, profile, members, leaderboard, badges, search, notifications, messages, saved, drafts.

NE.registerToolbarButton({
slug: SLUG,
id: "my-button",
icon: "fa-solid fa-flask", // FULL class with style prefix required
tip: "My button",
scope: "both", // "both", "posts", or "replies"
onClick({ attach, currentUser, context }) {
attach({ kind: "my_note", data: { text: "hello" } });
},
});

The icon field for toolbar buttons takes the full class with style prefix ("fa-solid fa-flask"), unlike other surfaces which take the short form ("fa-flask").

onClick receives { attach, currentUser, context }. Call attach({ kind, data }) to queue a composer attachment. context is "post", "reply", or null.

NE.registerProfileTab({ slug: SLUG, id: "my-tab", component: MyTabContent });

Component receives { username, current_user }. Tab label, icon, visibility, and priority all come from the manifest. The visibility: "own_only" setting hides the tab button but does not enforce access control — your component must do that.

NE.registerNotificationType("my_notif", {
icon: "fa-flask",
iconColor: "var(--ac)",
renderBody(n) { return React.createElement("span", null, "..."); },
onClick({ n }) { window.NexusExtensions.navigate(`/post/${n.data?.post_id}`); },
});

Without renderBody, Nexus renders a generic fallback message.

Adds extension content to both the forum-side moderation page (/moderation, visible to moderators and admins) and Admin → Moderation. A single call mounts your component in both places. Use this when your extension owns content that needs moderator review — approval queues, custom report queues, and so on.

NE.registerModerationSection({
slug: SLUG,
label: "Gallery",
logo_url: "/uploads/extensions/gallery/logo.png", // optional
approvals: {
badge: () => pendingApprovalCount, // function returning current count
component: GalleryApprovalsQueue,
},
reports: {
badge: () => pendingReportCount,
component: GalleryReportsQueue,
},
});

At least one of approvals or reports must be provided. Your component receives { currentUser, context } where context is "moderator" (forum-side panel) or "admin" (admin panel) — use this to show different controls in each location.

badge is called each render to display a count next to your section header — return 0 to suppress it.

The Extension Approvals and Extension Reports tabs are hidden when no extensions have registered for them — there is no visual footprint on installs that don’t use it.

Actions your component triggers must enforce permissions server-side in your Plug router using Permissions.check/3. Client-side mounting is a UI affordance, not access control.

Use raw fetch() for your own extension API endpoints:

const token = localStorage.getItem("nexus_token");
const r = await fetch(`/ext/${SLUG}/api/stats`, {
headers: token ? { "authorization": `Bearer ${token}` } : {}
});

The URL /ext/${SLUG}/api/stats maps to a get "/stats" route in your Plug.Router — the /api/ portion is stripped by Nexus before reaching your plug. Do not use window._nexusApi for extension API calls — it hardcodes the /api/v1 prefix.

For Nexus’s own /api/v1/* endpoints — notifications, uploads, and anything else under /api/v1 — use window._nexusApi. It handles JWT pass-through and 401 token refresh automatically:

// GET
const data = await window._nexusApi.get("/notifications/unread");
// POST with JSON body
await window._nexusApi.post("/notifications/extension", { ... });
// PATCH, DELETE
await window._nexusApi.patch("/some/path", { ... });
await window._nexusApi.delete("/some/path");
// File upload
await window._nexusApi.upload("/uploads/ext/your-slug", file, { type: "extension_image" });

All paths are relative to /api/v1. The canonical uses are notifications (below) and file uploads (below).

await window._nexusApi.post("/notifications/extension", {
slug: SLUG,
target_user_id: userId,
type: "my_notif",
data: { post_id: postId },
post_id: postId,
});

The actor is always taken from the JWT — notifications cannot be attributed to other users from the JS side. For actor-less notifications (scheduled jobs, webhooks), use Nexus.Notifications.notify_extension/3 from Elixir.

window.NexusExtensions.navigate("/ext/my-extension/page");
window.NexusExtensions.navigate("/feed");

Use navigate() everywhere — inside components, click handlers, and action callbacks.

const { url, original_url, upload, error } =
await window.NexusExtensions.uploadFile(file, {
slug: SLUG,
type: "extension_image", // or "extension_file"
// recordId: recordId, // optional — links to a record in your DB
});
if (error) throw new Error(error);
// use url, original_url, upload

uploadFile returns a promise resolving to { upload, url, original_url } on success or { error } on failure. It handles JWT pass-through and token refresh automatically.

extension_image accepts JPEG, PNG, GIF, WebP — auto-resizes and converts to WebP. extension_file accepts video, audio, PDF, ZIP, plain text, CSV, Markdown, JSON, and Office Open XML formats. SVG and executables are never accepted.

Uploaded files appear in Admin → Storage alongside core uploads. On uninstall, all extension uploads are automatically deleted.


window.React and window.ReactDOM are Nexus’s instances. Use hooks directly:

const { useState, useEffect, useCallback, useMemo, useRef, Fragment } = window.React;

Five ready-to-use components:

ComponentUse for
ToggleBoolean toggle switch. Props: value, onChange, label, hint
SelectStyled dropdown. Props: value, onChange, options (or raw <option> children)
AvAvatar component. Props: user ({username, avatar_url?}), size
MdRenders Nexus’s Markdown flavor. Props: text
toast(msg, type?)Function — fire-and-forget toast. Types: "err", "warn", or omit for success

Use CSS variables for theme-matched styling. Key variables:

VariablePurpose
--acAccent color
--bgPage background
--s1, --s2, --s3Surface levels (cards, popovers, overlays)
--t1--t5Text colors (highest to lowest contrast)
--b1, --b2, --b3Border colors
--green, --red, --amberSemantic colors
--av-radiusAvatar border-radius
  • .btn-primary — accent-colored button
  • .btn-ghost — subtle outlined button
  • .md-body — styles children like Nexus markdown, and auto-wires the image lightbox

Your extension runs in the Nexus VM and shares its dependency tree. No declaration needed:

Ecto, Ecto.SQL, Phoenix.PubSub, Oban, Req, Jason, Image (libvips), Floki, Swoosh, Joken, Bcrypt, ExAws, ExAws.S3, plus all of Elixir’s standard library.

If you need a package not in Nexus’s dependency tree, the install pipeline cannot fetch it — mix deps.get is not run for extension tarballs. Either vendor the code into lib/ or open a discussion about adding it to Nexus’s deps.