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.
Project layout
Section titled “Project layout”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.mdWhere files end up after install
Section titled “Where files end up after install”| Source path | Installed location |
|---|---|
manifest.json | Stored in the extensions.manifest DB column |
lib/**/*.ex | Compiled 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.
The Elixir module
Section titled “The Elixir module”Every extension module starts with:
defmodule MyExtension do use Nexus.Extensions.Behaviourenduse 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.
Reading settings
Section titled “Reading settings”Every callback that takes a settings argument receives the extension’s current settings map. Keys are always strings, not atoms:
# Correctsettings["enable_debug_log"]
# Wrong — returns nilsettings[:enable_debug_log]Use Map.get(settings, "key", default) when the field’s default matters at read time.
handle_event/3 — hooks
Section titled “handle_event/3 — hooks”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 truedef handle_event("post_created", %{"user_id" => user_id, "post_id" => post_id}, settings) do # do work :okend
# Catch-all required when hooks are declareddef handle_event(_event, _payload, _settings), do: :okHook event payloads:
| Event | Payload keys | Notes |
|---|---|---|
post_created | user_id, post_id | Actor is the post creator |
post_updated | user_id, post_id | Actor is the editor (may be a moderator) |
post_deleted | user_id, post_id | Post is already removed from the DB when this fires |
reply_created | user_id, reply_id, post_id | post_id is the parent post |
reply_deleted | user_id, reply_id, post_id | Clean up linked rows here |
reaction_added | user_id, emoji, post_id, reply_id | Exactly one of post_id/reply_id is non-nil |
reaction_removed | user_id, emoji, post_id, reply_id | Mirror of reaction_added |
report_created | user_id, report_id | Actor is the reporter |
report_resolved | user_id, report_id, status | Actor is the moderator; status is "reviewed", "dismissed", or "actioned" |
user_registered | user_id | user_id IS the new user |
user_login | user_id | user_id IS the user logging in |
All payload values are JSON-serializable (strings, numbers, booleans, nil, lists, maps). No structs, no DateTime, no PIDs.
Lifecycle callbacks
Section titled “Lifecycle callbacks”These are discrete-event callbacks — they do not run on Nexus restarts.
@impl truedef on_install(settings) do # Runs once on first install, after migrations have run :okend
@impl truedef on_update(from_version, to_version) do # Runs on update, after new migrations have run :okend
@impl truedef on_uninstall do # Runs before migrations roll back # Use for cleanup: deleting external files, revoking tokens :okendon_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.
migrations/0
Section titled “migrations/0”Returns a list of Ecto migration modules to run at install time and roll back at uninstall:
@impl truedef migrations do [ MyExtension.Migrations.V1CreateNotes, MyExtension.Migrations.V2AddIndex, ]endUse 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.
routes/0 — API plug routes
Section titled “routes/0 — API plug routes”Returns Elixir plug routes for your extension’s API. These are distinct from manifest routes (which are SPA page paths):
@impl truedef routes do [{"/", MyExtension.ApiRouter, []}]endThe 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"})) endendThe 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.
child_specs/0 — background processes
Section titled “child_specs/0 — background processes”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.
handle_digest_section/3
Section titled “handle_digest_section/3”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.
persist_attachment/3
Section titled “persist_attachment/3”Called when a user submits a post or reply with an attachment your extension’s toolbar button queued via attach():
@impl truedef persist_attachment("post", post_id, %{"kind" => "my_note", "data" => %{"text" => text}}) do # persist to your own tables :okend
def persist_attachment(_entity, _entity_id, _attachment), do: :okRuns 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.
Database access
Section titled “Database access”Use Nexus.Repo directly. Reference Nexus’s own tables by string name, not by aliasing internal schema modules:
# Stable — reference by string namefrom(u in "users", where: u.id == ^user_id, select: u.username) |> Nexus.Repo.one()
# Not stable — internal modules can changealias Nexus.Accounts.User # don't do thisFile storage
Section titled “File storage”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"Checking permissions
Section titled “Checking permissions”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"})endThe 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.
The JavaScript bundle
Section titled “The JavaScript bundle”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.
registerRoute
Section titled “registerRoute”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.
registerSlot
Section titled “registerSlot”NE.registerSlot({ slug: SLUG, slot: "post_footer", component: PostFooter, priority: 50 });Available slots and their props:
| Slot | Where it renders | Props |
|---|---|---|
post_footer | Below post body on /post/:id, above the reply thread | { post_id } |
profile_sidebar | Left rail of /profile/:username | { username, current_user } |
compose_attachments | Below the post body in the composer, above the footer bar | { attachments, set_attachments } |
Components receive only the declared props — nothing else is passed.
compose_attachments slot
Section titled “compose_attachments slot”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.
registerAdminPanel
Section titled “registerAdminPanel”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 aslugandfieldsarray.TabbedPanel— tab chrome with each tab’srenderfunction. NestSimpleSettingsPanelinside 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.
registerExploreItem
Section titled “registerExploreItem”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.
registerRightWidget
Section titled “registerRightWidget”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.
registerToolbarButton
Section titled “registerToolbarButton”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.
registerProfileTab
Section titled “registerProfileTab”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.
registerNotificationType
Section titled “registerNotificationType”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.
registerModerationSection
Section titled “registerModerationSection”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.
Calling your own API
Section titled “Calling your own API”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.
Calling Nexus core API endpoints
Section titled “Calling Nexus core API endpoints”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:
// GETconst data = await window._nexusApi.get("/notifications/unread");
// POST with JSON bodyawait window._nexusApi.post("/notifications/extension", { ... });
// PATCH, DELETEawait window._nexusApi.patch("/some/path", { ... });await window._nexusApi.delete("/some/path");
// File uploadawait 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).
Sending notifications from the bundle
Section titled “Sending notifications from the bundle”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.
Navigation
Section titled “Navigation”window.NexusExtensions.navigate("/ext/my-extension/page");window.NexusExtensions.navigate("/feed");Use navigate() everywhere — inside components, click handlers, and action callbacks.
Uploading files from the browser
Section titled “Uploading files from the browser”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, uploaduploadFile 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.
Host-provided UI primitives
Section titled “Host-provided UI primitives”window.React and window.ReactDOM are Nexus’s instances. Use hooks directly:
const { useState, useEffect, useCallback, useMemo, useRef, Fragment } = window.React;window.NexusComponents
Section titled “window.NexusComponents”Five ready-to-use components:
| Component | Use for |
|---|---|
Toggle | Boolean toggle switch. Props: value, onChange, label, hint |
Select | Styled dropdown. Props: value, onChange, options (or raw <option> children) |
Av | Avatar component. Props: user ({username, avatar_url?}), size |
Md | Renders Nexus’s Markdown flavor. Props: text |
toast(msg, type?) | Function — fire-and-forget toast. Types: "err", "warn", or omit for success |
CSS variables
Section titled “CSS variables”Use CSS variables for theme-matched styling. Key variables:
| Variable | Purpose |
|---|---|
--ac | Accent color |
--bg | Page background |
--s1, --s2, --s3 | Surface levels (cards, popovers, overlays) |
--t1–--t5 | Text colors (highest to lowest contrast) |
--b1, --b2, --b3 | Border colors |
--green, --red, --amber | Semantic colors |
--av-radius | Avatar border-radius |
Reusable CSS classes
Section titled “Reusable CSS classes”.btn-primary— accent-colored button.btn-ghost— subtle outlined button.md-body— styles children like Nexus markdown, and auto-wires the image lightbox
Available Elixir packages
Section titled “Available Elixir packages”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.