What is your developer-dependent CMS actually costing you? Calculate your costs and get a full report
What is your developer-dependent CMS actually costing you? Calculate your costs and get a full report
Overview
How to Create Apps in Agility
Build custom functionality that runs inside Agility CMS using the Apps SDK v2. This guide covers the end‑to‑end flow: planning your app, defining capabilities, implementing UI surfaces, local development, OAuth, installation, and best practices.
You only implement the surfaces you need. An app can be a single custom field, a sidebar, a dashboard, one or more modals, or a combination.
This guide is hosted at https://agilitycms.com/docs/apps/creating-apps-for-agility. For related topics, see:
@agility/app-sdk 2.2.1 declares react and react-dom ^18.2.0 as peer dependencies, so React 18.2 is the supported minimum. Agility's own PostHog app runs the SDK on React 19 with Next.js 15. If you use React 19 with npm, npm install reports a peer dependency conflict, so install with --legacy-peer-deps (or add an overrides entry) until the SDK's peer range includes React 19.We provide a complete “kitchen sink” example you can run and use as a reference:
npm run dev, npm run build, npm start/.well-known/agility-app.json (ensure CORS is enabled in your framework/server)app/ folder. If you’re using a different React setup (such as Vite), map each surface to a routable React component path your router can render.npm install @agility/app-sdk
Optional (for management API scenarios):
npm install @agility/management-sdk
Every app must expose /.well-known/agility-app.json. This file describes your app’s metadata, configuration options, OAuth connections, and the surfaces it provides.
Minimal example (only a custom field):
{
"name": "My Custom Field",
"version": "1.0.0",
"__sdkVersion": "2.0.0",
"capabilities": {
"fields": [{ "name": "my-field", "label": "My Field", "description": "Custom editor UI." }]
}
}
Kitchen‑sink example tips:
public/.well-known/agility-app.json (or serve it via middleware)headers() or your server config)Your app is a React application. Each surface should be a routable React component/page that the Agility CMS UI can load in an iframe.
If you use Next.js, each surface is a route under app/ (App Router):
app/fields/<field-name>/page.tsxapp/content-list-sidebar/page.tsxapp/content-item-sidebar/page.tsxapp/page-sidebar/page.tsxapp/home-dashboard/page.tsx, app/content-dashboard/page.tsx, app/pages-dashboard/page.tsxapp/modals/<modal-name>/page.tsxapp/install/page.tsxapp/api/app-uninstall/route.ts (or an API endpoint in your chosen framework)app/oauth/<connection>/page.tsxIf you use Vite or a custom React setup:
/fields/my-field, /modals/example-modal)./.well-known/agility-app.json from your static assets or an express middleware.// server.js (optional express for local dev)
import express from "express"
import fs from "fs"
import path from "path"
const app = express()
app.get("/.well-known/agility-app.json", (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*")
const json = fs.readFileSync(path.join(process.cwd(), "public/.well-known/agility-app.json"), "utf-8")
res.type("application/json").send(json)
})
app.use(express.static("dist"))
app.listen(3001, () => console.log("App running on http://localhost:3001"))
/.well-known/agility-app.json and each surface route.In each surface, call useAgilityAppSDK() to access context and SDK methods. Common properties:
initializing: boolean initialization state (render nothing until false)locale: current locale codeappInstallContext: install details including configurationinstance: current instance metadataAdditional properties per surface:
field, fieldValue, contentItemcontentItempageItemmodalPropsBelow are minimal React starters you can copy and expand. Examples use Next.js files, but the components work the same in any React router—adjust file paths accordingly.
File: app/fields/example-field/page.tsx
"use client"
import { useAgilityAppSDK, contentItemMethods, useResizeHeight } from "@agility/app-sdk"
export default function ExampleField() {
const { initializing, fieldValue } = useAgilityAppSDK()
const containerRef = useResizeHeight(10)
if (initializing) return null
return (
<div ref={containerRef}>
<textarea
className="w-full rounded border border-gray-300 p-4"
value={(fieldValue as string) || ""}
onChange={(e) => contentItemMethods.setFieldValue({ value: e.target.value })}
/>
</div>
)
}
Add to app definition:
{
"capabilities": {
"fields": [{ "name": "example-field", "label": "Example Field", "description": "Custom UI." }]
}
}
File: app/content-item-sidebar/page.tsx
"use client"
import { useAgilityAppSDK, contentItemMethods } from "@agility/app-sdk"
import { useEffect, useState } from "react"
export default function ContentItemSidebar() {
const { initializing, contentItem } = useAgilityAppSDK()
const [heading, setHeading] = useState<string>("")
useEffect(() => {
if (!contentItem?.values?.Heading) return
setHeading(contentItem.values.Heading as string)
}, [contentItem])
if (initializing) return null
return (
<div className="p-3">
<p>Heading: {heading}</p>
<button onClick={() => contentItemMethods.saveContentItem()}>Save</button>
<button
onClick={() =>
contentItemMethods.addFieldListener({
fieldName: "Heading",
onChange: (val) => setHeading(val as string)
})
}
>
Listen to Heading
</button>
</div>
)
}
Add to app definition:
{
"capabilities": {
"contentItemSidebar": { "description": "Helper tools for content editing." }
}
}
File: app/page-sidebar/page.tsx
"use client"
import { useAgilityAppSDK, pageMethods } from "@agility/app-sdk"
import { useState } from "react"
export default function PageSidebar() {
const { initializing } = useAgilityAppSDK()
const [page, setPage] = useState<any>()
if (initializing) return null
return (
<div>
<button onClick={async () => setPage(await pageMethods.getPageItem())}>Get Page</button>
<pre className="whitespace-pre-wrap break-words text-xs">{JSON.stringify(page, null, 2)}</pre>
</div>
)
}
Files: app/home-dashboard/page.tsx, app/content-dashboard/page.tsx, app/pages-dashboard/page.tsx
"use client"
import { useAgilityAppSDK, useResizeHeight, assetsMethods } from "@agility/app-sdk"
export default function Dashboard() {
const { initializing, locale, appInstallContext } = useAgilityAppSDK()
const ref = useResizeHeight()
if (initializing) return null
return (
<div ref={ref} className="p-4">
<h1 className="text-xl font-semibold">Dashboard</h1>
<div>Locale: {locale}</div>
<div>Config: {JSON.stringify(appInstallContext?.configuration)}</div>
<button
onClick={() =>
assetsMethods.selectAssets({
title: "Select Assets",
singleSelectOnly: false,
callback: console.log
})
}
>
Select Assets
</button>
</div>
)
}
Capabilities (choose which):
{
"capabilities": {
"homeDashboard": { "description": "Instance-level analytics." },
"contentDashboard": { "description": "Content insights." },
"pagesDashboard": { "description": "Pages insights." }
}
}
File: app/modals/example-modal/page.tsx
"use client"
import { closeModal, useAgilityAppSDK } from "@agility/app-sdk"
export default function ExampleModal() {
const { initializing, modalProps } = useAgilityAppSDK()
if (initializing) return <div>Initializing...</div>
return (
<div className="flex h-full flex-col gap-3 p-3">
<h2 className="text-lg font-semibold">Example Modal</h2>
<div className="flex-1">Props: {JSON.stringify(modalProps)}</div>
<div className="flex gap-2">
<button onClick={() => closeModal({ btn: "ok" })}>OK</button>
<button onClick={() => closeModal({ btn: "cancel" })}>Cancel</button>
</div>
</div>
)
}
Open from any surface:
import { openModal } from "@agility/app-sdk"
openModal({
title: "Example Modal",
name: "example-modal",
props: { foo: "bar" },
callback: (result) => console.log("Modal result:", result)
})
Add to app definition:
{
"capabilities": {
"modals": [{ "name": "example-modal", "label": "Example Modal", "description": "Reusable dialog." }]
}
}
Add a pre‑install screen to collect extra configuration values.
File: app/install/page.tsx
"use client"
import { setExtraConfigValues, useAgilityPreInstall } from "@agility/app-sdk"
export default function Install() {
const { initializing } = useAgilityPreInstall()
if (initializing) return null
return (
<div>
<h1>Install</h1>
<button onClick={() => setExtraConfigValues([{ name: "apiKey", value: "xyz123" }])}>
Complete Install
</button>
</div>
)
}
Enable in app definition:
{ "capabilities": { "installScreen": true } }
Declare OAuth connections in the app definition and implement a matching route.
App definition snippet:
{
"connections": [
{
"name": "Agility API Offline Access",
"icon": "https://cdn.aglty.io/content-manager/images/logo-triangle-only-yellow.svg",
"url": "/oauth/agility-api-offline"
}
]
}
Route: app/oauth/agility-api-offline/page.tsx
"use client"
import { useEffect } from "react"
export default function AgilityAPI() {
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const code = params.get("code")
const redirect_uri = params.get("redirect_uri")
if (code && redirect_uri) {
const formData = new FormData()
formData.append("code", code)
fetch("https://mgmt.aglty.io/oauth/token", { method: "POST", body: formData })
.then((r) => r.text())
.then((token) => (window.location.href = `${redirect_uri}#${encodeURIComponent(token)}`))
} else {
const authUrl = `https://mgmt.aglty.io/oauth/authorize?response_type=code&redirect_uri=${encodeURIComponent(
window.location.href
)}&scope=offline_access`
window.location.href = authUrl
}
}, [])
return <div>Authenticating...</div>
}
Handle cleanup when an app is uninstalled.
File: app/api/app-uninstall/route.ts
export async function POST(request: Request) {
const body = await request.json()
// Clean up resources, stored data, and tokens
return new Response("OK", { status: 200 })
}
Enable in app definition:
{ "capabilities": { "uninstallHook": "/api/app-uninstall" } }
npm install
npm run dev
http://localhost:3001/.well-known/agility-app.json./.well-known/agility-app.json.initializing === false before rendering UI.useResizeHeight() in fields and dashboards for automatic iframe sizing.contentItemMethods.setFieldValue() and that the field name matches the model.name matches the definition and the route exists under app/modals/<name>/page.tsx./.well-known/agility-app.json (see next.config.js)./.well-known/agility-app.json and list only the surfaces you implement.useAgilityAppSDK(), and prefer useResizeHeight() where embedded.