Most of the Grav 2.0 developer series so far has been quick wins. The compatibility flag is a one-line change. Adding API endpoints is a couple of event handlers. Custom blueprint field types are the one place where you've actually got work to do, because the way they're built has genuinely changed in 2.0.
The good news up front: it's a smaller job than it sounds. The contract is tiny, registration is just dropping a file in the right folder, and you almost certainly don't need a build step. If you ever wrote a Twig and jQuery custom field for classic admin, most of this will feel familiar. The runtime is plain web platform stuff: HTMLElement, properties, events, fetch. No framework, no toolchain.
The full reference lives on the Learn site at Custom Admin Fields via Web Components. This post is the walk-through, with the why and a couple of worked examples.
First, What Is a Custom Field?
If you're not a developer, here's the quick version so the rest of this makes sense. Every setting you edit in the Grav admin is a field: the title box on a page, the on/off toggles in a plugin's settings, the dropdowns, the color picker, the media browser, the markdown editor itself. Grav ships a large library of standard field types, and blueprints (the small YAML files that describe a form) pick the right one for each setting.
Sometimes a plugin needs a field that the standard library doesn't cover. A syntax-highlighting theme picker that shows live code previews. A licensing panel that pulls product status from a server. A specialized media tool. Those are custom fields, and the plugin that needs one ships it itself.
In Grav 1.x a custom field was built one way. In Grav 2.0 with Admin Next, it's built a different way. If you're a site owner rather than a developer, the takeaway is simple: the custom fields in your favorite plugins keep working, the plugin author just ships a small update for 2.0. If you're that plugin author, the rest of this post is your hands-on guide to making that update, and the good news is that it's a smaller job than it sounds.
The Architecture Shift
In classic admin, a custom field was a PHP class plus a Twig template. The Twig template rendered on the server, the resulting HTML got embedded in the page, and any client-side interactivity was layered on top with jQuery. The field was tightly coupled to admin's rendering pipeline. It had to be, because admin itself was a server-rendered Twig application.
Admin Next is an API-driven SPA. There is no server-side Twig rendering for the admin UI anymore. The admin is built once at deploy time, served as static assets, and everything dynamic happens through the API plugin. That means a custom field can't be a Twig template; there's nothing to render it. It has to be something the browser can run on its own.
The natural fit for that is a Custom Element (the web component standard). Admin Next encounters an unknown field type in a blueprint, fetches the JavaScript file for that type from the API, defines the element, and mounts it inside the form. The element handles its own rendering, takes the field config and current value as properties, and emits a change event when the value changes. Admin Next handles the rest (save, validation, blueprint logic, dirty-state tracking).
The end result is more powerful than the classic model, because the field can do almost anything a browser can do, and it doesn't have to round-trip through the server to update its own UI.
How Discovery Works
You don't register custom fields anywhere in PHP. The API plugin scans your plugin's admin-next/fields/ directory at request time and infers everything from the filesystem.
your-plugin/
├── admin-next/
│ └── fields/
│ ├── yourfieldtype.js # one .js file per custom field type
│ └── anotherfield.js
The filename (minus .js) is the field type identifier you'll use in your blueprint:
form:
fields:
my_setting:
type: yourfieldtype # matches admin-next/fields/yourfieldtype.js
label: A custom setting
When Admin Next loads, it calls GET /custom-fields once to pull the full registry of {type → plugin} mappings. When it later encounters type: yourfieldtype in a blueprint, it knows which plugin owns it, and it loads the JavaScript on demand from GET /gpm/plugins/{slug}/field/{type} (or /gpm/themes/{slug}/field/{type} for theme-provided fields).
Themes can ship custom fields too. Same convention, same lookup, just under the theme's admin-next/fields/ directory.
There's no event you have to subscribe to, no array you have to populate. Drop the file in the right place and the API plugin does the rest.
The Web Component Contract
Each .js file in admin-next/fields/ must define a Custom Element using the tag name that Admin Next provides via a global. The minimum viable field component looks like this:
const TAG = window.__GRAV_FIELD_TAG;
class MyFieldType extends HTMLElement {
_field = null;
_value = null;
// Properties set by Admin Next
set field(f) { this._field = f; this._render(); }
set value(v) { this._value = v; this._render(); }
get value() { return this._value; }
connectedCallback() {
this.attachShadow({ mode: 'open' });
this._render();
}
_render() {
if (!this.shadowRoot) return;
this.shadowRoot.innerHTML = `
<style>
input { width: 100%; padding: 6px 8px; }
</style>
<input value="${this._value ?? ''}" />
`;
this.shadowRoot.querySelector('input')
.addEventListener('input', e => this._emitChange(e.target.value));
}
_emitChange(newValue) {
this._value = newValue;
this.dispatchEvent(new CustomEvent('change', {
detail: newValue,
bubbles: true,
}));
}
}
customElements.define(TAG, MyFieldType);
That's the whole contract. Three things to get right:
Tag name comes from a global. Admin Next assigns the tag name at load time and exposes it via window.__GRAV_FIELD_TAG. Always read it from there. Don't hard-code a tag. The reason is that Admin Next namespaces each field's tag per plugin so two plugins can ship a field with the same type name without colliding.
Admin Next pushes data into your element via properties. You get field (the blueprint field definition, with all of label, help, options, validate, and any custom keys you defined) and value (the current value of the field). Implement them as setters and re-render on assignment.
You push value changes back via a change event. Dispatch a CustomEvent('change') with detail set to the new value. Bubble it (bubbles: true). Admin Next listens on the element and updates its own form state accordingly. That's the only event you need to fire.
Everything else (Shadow DOM, custom styling, internal state, validation hints, third-party libraries) is your call. Admin Next doesn't care how you build the UI, it only cares about the contract.
The Globals Admin Next Injects
Before Admin Next executes your script, it sets a small set of globals on window. These are your interface with the rest of the admin runtime:
| Global | Purpose |
|---|---|
window.__GRAV_FIELD_TAG |
The Custom Element tag name assigned to your field (per-plugin namespaced). |
window.__GRAV_API_SERVER_URL |
Base URL of the Grav site (e.g. https://mysite.com). |
window.__GRAV_API_PREFIX |
API prefix (default /api/v1). |
window.__GRAV_API_TOKEN |
JWT access token already obtained by Admin Next, ready to send. |
window.__GRAV_ADMIN_BASE |
Base path of Admin Next, useful for building internal links. |
window.__GRAV_NAVIGATE |
Function for SPA navigation. Falls back to window.location.href if not present. |
window.__GRAV_DIALOGS |
Admin Next's confirm/alert dialog helper (preferred over the native confirm()). |
A standard helper most components end up writing once and reusing:
function apiHeaders() {
const headers = {};
const token = window.__GRAV_API_TOKEN;
if (token) headers['X-API-Token'] = token;
return headers;
}
function apiUrl(path) {
const base = window.__GRAV_API_SERVER_URL || '';
const prefix = window.__GRAV_API_PREFIX || '/api/v1';
return `${base}${prefix}${path}`;
}
One non-obvious detail worth calling out: send your auth token as the X-API-Token header, not as Authorization: Bearer. Both work in principle, but FastCGI and PHP-FPM setups (a default MAMP install is the usual culprit) silently strip the Authorization header before it reaches PHP. The X-* family passes through cleanly. This is the single most common "but my fetch was authenticated" debugging session, so save yourself the half hour and use X-API-Token from the start.
And one thing the field runtime does not hand you: there's no environment global for field components. Admin Next manages the active environment through its own authenticated session, so you don't need to send an X-Grav-Environment header from a field, and there's no window.__GRAV_ENVIRONMENT to read. If you've seen that header in older field code, it was a no-op. Stick to the token and you're done.
Worked Example 1: A Read-Only Status Field
The simplest useful custom field is one that displays data fetched from a custom API endpoint. Here's the pattern, lifted almost verbatim from the license-manager plugin's products-status field:
const TAG = window.__GRAV_FIELD_TAG;
class ProductsStatus extends HTMLElement {
_field = null;
_value = null;
set field(v) { this._field = v; }
set value(v) { this._value = v; if (this.isConnected) this._loadStatus(); }
get value() { return this._value; }
connectedCallback() {
this._render();
this._loadStatus();
}
_render() {
this.innerHTML = `
<style>
.row { display: flex; align-items: center; gap: 10px;
padding: 8px 0; border-bottom: 1px solid #e5e7eb; font-size: 13px; }
.row:last-child { border-bottom: none; }
.icon.enabled { color: #16a34a; }
.icon.disabled { color: #f59e0b; }
.icon.not-installed { color: #3b82f6; }
.empty { padding: 12px; color: #6b7280; font-style: italic; font-size: 13px; }
</style>
<div class="container"><div class="empty">Loading product status...</div></div>
`;
}
async _loadStatus() {
const headers = {};
const token = window.__GRAV_API_TOKEN;
if (token) headers['X-API-Token'] = token;
const base = window.__GRAV_API_SERVER_URL + (window.__GRAV_API_PREFIX || '/api/v1');
try {
const resp = await fetch(`${base}/licenses/products-status`, { headers });
const json = await resp.json();
this._renderRows(json.data || []);
} catch {
this.querySelector('.container').innerHTML =
`<div class="empty">Unable to load product status.</div>`;
}
}
_renderRows(items) {
const container = this.querySelector('.container');
if (!items.length) {
container.innerHTML = `<div class="empty">No licensed products found.</div>`;
return;
}
container.innerHTML = items.map(item => `
<div class="row">
<span class="icon ${item.status}">●</span>
<span><strong>${item.slug}</strong> (${item.status})</span>
</div>
`).join('');
}
}
customElements.define(TAG, ProductsStatus);
A few things this example teaches:
- It's a read-only field, so it never emits a
changeevent.valueis still implemented as a setter so the component can react when the parent form's data changes. - It uses light DOM (
this.innerHTML) rather than Shadow DOM. That's a trade-off: light DOM picks up Admin Next's typography and color tokens automatically; Shadow DOM gives you total isolation. For simple read-only displays, light DOM is usually less code. - It loads data lazily on
connectedCallbackrather than on the property setter. That keeps Admin Next's initial render cheap.
The blueprint side is correspondingly simple:
form:
fields:
products_status:
type: products-status
style: vertical
The field type matches the filename. Everything else flows from there.
Worked Example 2: A Field That Mutates State
A more interesting case is a field that fetches data, lets the user act on it, and writes back. The codesh plugin's codeshgrammarlist field is a clean reference: it loads built-in and custom syntax grammars, lets the user import a new one or delete an existing one, and updates the list in place.
The structural skeleton (trimmed to the interesting parts):
const TAG = window.__GRAV_FIELD_TAG;
function apiHeaders() {
const h = {};
if (window.__GRAV_API_TOKEN) h['X-API-Token'] = window.__GRAV_API_TOKEN;
return h;
}
function apiUrl(path) {
const base = window.__GRAV_API_SERVER_URL || '';
const prefix = window.__GRAV_API_PREFIX || '/api/v1';
return `${base}${prefix}${path}`;
}
class GrammarListField extends HTMLElement {
_field = null;
_value = null;
_grammars = null;
set field(f) { this._field = f; }
set value(v) { this._value = v; }
get value() { return this._value; }
connectedCallback() {
this.attachShadow({ mode: 'open' });
this._render();
this._loadGrammars();
}
async _loadGrammars() {
const resp = await fetch(apiUrl('/codesh/grammars'), { headers: apiHeaders() });
const json = await resp.json();
this._grammars = json.data || json;
this._render();
}
async _importGrammar() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
const form = new FormData();
form.append('file', file);
const resp = await fetch(apiUrl('/codesh/grammars/import'), {
method: 'POST',
headers: apiHeaders(),
body: form,
});
if (!resp.ok) return alert('Import failed.');
this._loadGrammars();
};
input.click();
}
async _deleteGrammar(slug) {
const ok = await (window.__GRAV_DIALOGS?.confirm({
title: 'Delete custom grammar?',
message: `The grammar "${slug}" will be permanently removed.`,
confirmLabel: 'Delete grammar',
variant: 'destructive',
}) ?? Promise.resolve(window.confirm(`Delete custom grammar "${slug}"?`)));
if (!ok) return;
await fetch(apiUrl(`/codesh/grammars/${encodeURIComponent(slug)}`), {
method: 'DELETE',
headers: apiHeaders(),
});
this._loadGrammars();
}
_render() {
// ... render grammars, wire up import/delete buttons ...
}
}
customElements.define(TAG, GrammarListField);
The takeaways:
- Reuse a small
apiHeaders()/apiUrl()helper at the top of every field component. You'll write the same six lines in every field otherwise. - The component talks to its own plugin's custom API endpoints. This is the natural pairing with the previous post in the series: you ship your endpoints with
onApiRegisterRoutes, and your field components consume them. - Use
window.__GRAV_DIALOGS?.confirm()with awindow.confirm()fallback for destructive actions. Admin Next's dialog system styles them to match the rest of the UI and respects keyboard/focus management; the native fallback is there for the (rare) case the helper isn't loaded. - Reload the list after a mutation rather than trying to update the component's internal state surgically. It's fewer moving parts, and the round-trip is cheap.
Modals and Overlays
Fields that need a modal (a picker, a color wheel, a media browser) hit one specific snag worth knowing about: rendering the modal inside the Shadow DOM works in isolation, but the form layout above the field has its own overflow constraints, and your modal will get clipped.
The fix is to append the modal to document.body rather than to your component:
_openModal() {
const modal = document.createElement('div');
modal.id = '__my-plugin-modal';
modal.innerHTML = `
<style>
#__my-plugin-modal {
position: fixed; inset: 0; z-index: 9999;
background: rgba(0,0,0,0.5);
display: flex; align-items: center; justify-content: center;
}
#__my-plugin-modal .panel {
background: white; border-radius: 8px; padding: 24px;
min-height: auto; /* see warning below */
}
</style>
<div class="panel">...</div>
`;
document.body.appendChild(modal);
}
_closeModal() {
document.getElementById('__my-plugin-modal')?.remove();
}
Watch out for Tailwind v4. Admin Next uses Tailwind, and Tailwind v4 sets a global * { min-height: 0 } reset that can collapse modals appended to the body. Defensively set min-height: auto on any container in your modal that needs to actually contain content.
You'll also lose the style isolation that Shadow DOM gave you, so use a unique id/class prefix on every element in your modal (#__my-plugin-modal, .__my-plugin-modal-row, etc.) to avoid colliding with the host page's styles.
Supporting Both Classic Admin and Admin Next
If your plugin needs to keep working in classic admin (for sites still on 1.7) as well as Admin Next (for 2.0), you'll be maintaining two field implementations for a while: the old Twig+jQuery one and the new web component. The core Team plugins do exactly that, and they keep the two as separate files. The codesh plugin, for instance, ships admin/js/codeshtheme-field.js for classic admin and admin-next/fields/codeshtheme.js for Admin Next, side by side:
your-plugin/
├── admin/
│ └── js/
│ └── my-field.js # classic admin: jQuery UI
└── admin-next/
└── fields/
└── myfieldtype.js # Admin Next: web component
For a small field, that bit of duplication is the path of least resistance, and I wouldn't fight it. If your field carries real shared logic (input validation, data parsing, a non-trivial API client), you can factor that into a plain ESM module somewhere under your plugin and import it from both sides: the web-component side imports it at runtime, the classic side bundles it through whatever build it already had. No new toolchain required either way. Just don't reach for the abstraction before the duplication actually starts to hurt.
Blueprint Conventions
The blueprint side of a custom field is exactly the same as every other Grav blueprint, with one wrinkle: your component receives the full field definition object via the field property setter, so you can use any custom keys you want and pick them up from inside the component.
The codesh theme picker uses this for a variant key:
theme_dark:
type: codeshtheme
label: PLUGIN_CODESH.DARK_THEME
help: PLUGIN_CODESH.DARK_THEME_HELP
variant: dark
default: helios-dark
theme_light:
type: codeshtheme
label: PLUGIN_CODESH.LIGHT_THEME
help: PLUGIN_CODESH.LIGHT_THEME_HELP
variant: light
default: github-light
Inside the component:
set field(f) {
this._field = f;
this._variant = f.variant ?? 'dark';
this._render();
}
Use translation keys (PLUGIN_<MYPLUGIN>.*) for label and help so the strings live in your languages/<lang>.yaml and benefit from the full i18n lookup chain. Admin Next resolves them client-side, just like the rest of the admin.
Common Gotchas
A short list of things that catch people the first time:
- Always read the tag from
window.__GRAV_FIELD_TAG. Hard-coding the tag works in isolated testing and breaks the moment two plugins ship a field with the same type identifier. - Light DOM picks up Admin Next styles. Shadow DOM doesn't. That's a feature, not a bug, but choose deliberately. Read-only displays usually want light DOM. Anything with significant custom styling usually wants Shadow DOM.
- Modals go in
document.body, not in your component. And remember the Tailwindmin-height: 0reset. X-API-TokenbeatsAuthorization: Beareron FastCGI/FPM setups. Always sendX-API-Token.- Use
window.__GRAV_DIALOGS?.confirm()for destructive actions. It looks right and keyboard-handles right. - Escape user-supplied strings before injecting into
innerHTML. A tiny_esc(s)helper (set on a throwaway<div>viatextContent, then read back asinnerHTML) goes a long way. - Reload after mutations. Don't try to keep your component state in sync with the server by hand. Re-fetch.
- Don't fire your own change events while you're applying the parent's value. That's a classic infinite-loop. Either gate the dispatch behind a user-initiated event handler, or set an internal
_applying = trueflag while reacting to property writes.
Real-World Examples to Read
When you're starting your first custom field, having a working component to read alongside the docs is gold:
- grav-plugin-license-manager:
admin-next/fields/products-status.js, a read-only status field that fetches from a custom endpoint. - grav-plugin-codesh:
admin-next/fields/codeshtheme.js(a full picker modal over 60+ themes with search and filters) andadmin-next/fields/codeshgrammarlist.js(multi-column list with import and delete).
Both are short, both are openly available, and between them they cover almost every pattern you'll need.
Let an Agent Do the Boilerplate
I'll be honest about how a lot of the Admin Next field work actually got built: I handed it to Claude Code. Custom fields are patterned and mechanical, the same contract every time and the same handful of gotchas, and that's exactly the kind of work an AI agent is good at, as long as it knows the patterns.
So the Grav team ships the patterns to it. There's a bundle of Claude Code skills at github.com/getgrav/grav-skills that teach an agent how Grav 2.0 plugin development actually works: the web component contract, the filesystem discovery, the injected globals, the X-API-Token detail, the modal-in-document.body trick, all of it. Everything in this post is encoded in there, plus pointers to the real premium plugins so the agent has working code to read instead of guessing.
Install them in Claude Code with one line:
/plugin marketplace add getgrav/grav-skills
The one you want here is grav-api-admin-next-integration, which covers custom fields along with the rest of the Admin Next surface. Pair it with grav-translations if your field has labels and help text. Once they're in, you can skip the scaffolding and just describe what you want: "add a custom field type called color-ramp that lets the user build a gradient and saves it as an array of stops." The agent drops the file in admin-next/fields/color-ramp.js, wires up the blueprint, reads the tag from window.__GRAV_FIELD_TAG, emits change the way Admin Next expects, and steers around the mistakes this post spent paragraphs warning you about, because the skill already knows them.
If you've also got the Grav MCP server pointed at a dev site, the same agent can install the field, drop it into a blueprint, and check that it renders, without you leaving the conversation.
None of this replaces understanding the contract, which is why the rest of this post exists. But once you understand it, you rarely need to type it out by hand again. The full writeup of the skills and what each one covers is on the Learn site under AI-Assisted Development.
FAQ
Do I need a build step?
No. Every field component shipped by the core Grav Team plugins is a single hand-written .js file, no bundler, no transpiler, no node_modules. Modern browsers handle everything you'll need (private class fields, optional chaining, top-level await, import() if you really need lazy loading). Save yourself the toolchain.
Can I use a framework inside my field component?
You can, but think hard before you do. The contract is one element, one tag, one entry point. If you want to mount Vue, React, or Svelte inside that element, you're free to. The cost is the framework bundle size on every form that uses the field, plus a second mental model layered on top of the Custom Element one. For most fields, hand-written DOM is faster, smaller, and easier to maintain.
How do I integrate with Admin Next's validation system?
The blueprint's existing validate: block runs in Admin Next the same way it ran in classic admin. Set required, pattern, min, max and friends in the blueprint and Admin Next will show errors against the form. Your component doesn't need to do anything special, but if you want to render an inline error inside your field, listen for the same validation events the rest of the admin uses and update your render. The simplest version: trust the blueprint, emit clean values via change, and let Admin Next show the validation summary.
What about accessibility?
Treat your custom element the same as any other interactive control on the page. Use semantic HTML where possible (<button> instead of <div onclick>), wire up keyboard handlers, set aria-label and aria-describedby where appropriate, and respect prefers-reduced-motion for any animations. Shadow DOM does interact with accessibility tooling, so test with at least one screen reader if your field is interactive.
Can themes ship custom fields too?
Yes. Same convention, just under the theme directory: your-theme/admin-next/fields/yourfieldtype.js. The API serves it via GET /gpm/themes/{slug}/field/{type}.
Does my plugin need to declare anything in PHP?
No. Custom field discovery is purely filesystem-based. If you have a file at admin-next/fields/yourfieldtype.js, the API plugin will find it and serve it. You only need PHP if your field talks to a custom endpoint (which is the previous post in this series).
Quick Links and Help
- Learn site reference: Custom Admin Fields via Web Components.
- API endpoints used by Admin Next to load fields:
GET /custom-fields,GET /gpm/plugins/{slug}/field/{type}. - Reference implementations on GitHub: grav-plugin-license-manager, grav-plugin-codesh.
- Previous post in the developer series: Grav 2.0 Developer Guide: API Integration for Plugins.
- Discord: chat.getgrav.org for any "is this working as expected?" questions.
If you only do one Admin Next integration for your plugin, make it this one. Custom fields show up everywhere your plugin has settings, and they cost you a single file with no build step. And once you've written one, the rest of the Admin Next extension points (pages, panels, widgets, reports) work the same way: a web component plus an API endpoint. Learn it here and you've learned all of them.
Andy
Next in the developer series: Grav 2.0 Developer Guide: Admin Next Custom Pages