EXT:headless 

Version

main

Language

en

Description

Headless allows you to render JSON from TYPO3 content. You can customize output by changing types, names and nesting of fields.

This extension provides the backend part (JSON API) for TYPO3 PWA solution. The frontend part exists as JavaScript application nuxt-typo3 which consumes the JSON API and renders the content using Nuxt framework of VueJS. You can find the frontend documentation here.

Keywords

headless, json, api

Copyright

2026 by TYPO3 Association

Authors
  • Tymoteusz Motylewski (Macopedia)
  • Łukasz Uznański (Macopedia)
  • Adam Marcinkowski (Macopedia)
  • Vaclav Janoch (ITplusX)
Email

extensions@macopedia.pl

License

This extension is published under the GNU General Public License v2.0

Rendered

Wed, 02 Sep 2026 12:07:58 +0000

Feedback & Credits

If you have any questions just drop a line in our #initiative-pwa Slack channel.

Special thanks goes to macopedia.com company, which is sponsoring development of this solution.

TYPO3

The content of this document is related to TYPO3 CMS, a GNU/GPL CMS/Framework available from typo3.org .

For Contributors

You are welcome to help improve this guide if you missing something. Just click on "Edit me on GitHub" on the top right to submit your change request or report a problem

Table of Contents

Introduction 

EXT:headless renders TYPO3 pages and content as JSON. The response shape is plain TypoScript — field names, types and nesting are customised with tools TYPO3 integrators already know. Any frontend that speaks JSON can consume it; nuxt-typo3 is the reference implementation.

What you get 

  • JSON API for pages, content elements, menus, metadata — with full language/fallback handling
  • Per-site modes: full JSON, mixed (JSON only for Accept: application/json), off
  • Extensible via TypoScript: custom fields, custom content elements
  • Installed core extensions integrate automatically: EXT:form, EXT:felogin, EXT:redirects (JSON envelopes), EXT:seo (meta tags, XML sitemap); workspace preview works out of the box
  • Community add-ons: news, solr, powermail, gridelements, container

Version support 

headless TYPO3 PHP Status
5.x 14 >= 8.2 Active development
4.x 12.4 – 13 >= 8.2 Bug & security fixes
3.x 11.5 End of life

Upgrading? See UPGRADE.md.

Getting Started 

Five-minute path from a fresh TYPO3 install to your first JSON response.

1. Install 

composer require friendsoftypo3/headless
Copied!

Composer is the recommended way. Classic mode still works but takes manual steps: download the release, unpack it to typo3conf/ext/headless, then activate it in the Extension Manager.

2. Create a root page + site config 

In the backend, create a new page at the root level (the site root). Then go to Site Management → Sites and add a site configuration pointing at that page.

3. Add the headless set and switch the site to JSON 

Both switches live in the site configuration:

# config/sites/<identifier>/config.yaml
rootPageId: 1
base: https://api.example.com
dependencies:
  - friendsoftypo3/headless
headless: 1  # 0 = NONE, 1 = FULL (always JSON), 2 = MIXED (Accept-driven)
Copied!

In MIXED mode the first Accept header value must be exactly application/jsonAccept: application/json, text/plain, */* (the axios/fetch default) or application/json; charset=utf-8 renders HTML.

Do not add a root sys_template record — sites using sets don't need one, and its "Clear" flags would wipe the set TypoScript. Sites not using sets can select the equivalent statics in a root TypoScript record instead: "Headless", "Headless Legacy (4.x)" or "Headless - Mixed mode JSON response".

Headless 4.x ships sets too (TYPO3 v13): friendsoftypo3/headless (full response) and friendsoftypo3/headless-mixed. On TYPO3 v12 include the headless static template in the root TypoScript record instead.

Set and mode variants are described in Configuration.

4. Drop a content element on the page 

Add a Text element (or any standard CE) to the page. Save.

5. Fetch the page 

curl https://api.example.com/
Copied!

You should get something like:

{
  "id": 1,
  "slug": "/",
  "seo": { "title": "Home" },
  "breadcrumbs": [{ "title": "Home", "link": "/" }],
  "content": {
    "colPos0": [{ "id": 1, "type": "text", "content": { "bodytext": "…" } }]
  }
}
Copied!

See Reference: TypoScript helpers for what each field means and where to override it.

What next? 

Configuration 

Site configuration 

Headless is enabled per site in config/sites/<identifier>/config.yaml:

dependencies:
  - friendsoftypo3/headless
headless: 1
Copied!

dependencies — pick one site set:

  • friendsoftypo3/headless — trimmed default response (new projects).
  • friendsoftypo3/headless-legacy — full 4.x-compatible response (upgrades).
  • friendsoftypo3/headless-mixed — JSON only for requests sent with exactly Accept: application/json; everything else renders your own HTML page. The JSON it serves is the full 4.x-shaped (legacy) response, not the trimmed default one.

headless — run mode: 0 off, 1 always JSON, 2 mixed (Accept-driven — pair it with the mixed set). In mixed mode the first Accept header value must match exactly: Accept: application/json, text/plain, */* (the axios/fetch default) or application/json; charset=utf-8 renders HTML. A site using sets must not carry a root sys_template record: its "Clear" flags wipe all set-provided TypoScript. Sites not using sets can select the equivalent sys_template statics instead ("Headless", "Headless Legacy (4.x)", "Headless - Mixed mode JSON response").

Headless 4.x ships sets as well (TYPO3 v13): friendsoftypo3/headless (there: the full response) and friendsoftypo3/headless-mixed. On TYPO3 v12 include the headless static template in the root TypoScript record instead.

URLs pointing at the frontend domain (frontendBase, frontendApiProxy, per-language variants) are covered in Multi-Site & URL Configuration.

Automatic integrations 

Headless detects installed core extensions and integrates them without further setup: EXT:form (JSON form definitions, form editor additions), EXT:felogin (JSON login plugin), EXT:redirects (JSON redirect envelopes, frontend-aware backend modules) and EXT:seo (canonical/meta-tag managers, hreflang rewriting). Workspace preview needs no setup either — core rendering plus the backend preview-URL rewrite cover it. Whether a request gets the headless behaviour follows the site's mode: always with headless: 1, only for exact Accept: application/json requests with headless: 2.

Feature flags 

Set flags in settings.php/additional.php:

$GLOBALS['TYPO3_CONF_VARS']['SYS']['features']['<flag>'] = true;
Copied!

headless.storageProxy — serve processed files through the site's frontendApiProxy/frontendFileApi instead of the TYPO3 host.

headless.elementBodyResponse — on POST/PUT/DELETE requests return only the element matching responseElementId from the request body — clean plugin output for SPA form handling:

POST https://example.tld/path-to-form-plugin
Content-Type: application/x-www-form-urlencoded

responseElementId=#ELEMENT_ID#&tx_form_formframework[email]=email&...
Copied!

Set responseElementRecursive=1 to match a nested (child) element. On a mixed-mode site (headless: 2) the POST itself must carry the exact Accept: application/json header, or the middleware does not act.

headless.overrideFluidTemplates — swap the core ViewFactoryInterface for HeadlessViewFactory: Fluid views render JSON templates, raw-PHP templates (HeadlessPhpView) are supported too.

To render an Extbase plugin through a raw-PHP template, set the plugin's request format and place the template at <templateRootPaths>/<ControllerName>/<ActionName>.php (highest-keyed root path wins):

plugin.tx_myext.view.templateRootPaths.100 = EXT:site_package/Resources/Private/Templates/
plugin.tx_myext_pi1.format = php
Copied!

An explicit $view->render('Path/Name') from your own code resolves Path/Name.php against the same root paths.

Template selection is not user-controllable: format is a regular Extbase request parameter, but HeadlessViewFactory serves the raw-PHP view only when the dispatched plugin's TypoScript sets format = php. A request-supplied tx_myext_pi1[format]=php on any other plugin falls back to Fluid with the format stripped, so Fluid never parses a .php file as a template. A visitor forcing format=html on a php-configured plugin just gets the plugin's regular Fluid resolution — standard Extbase format behavior.

Unlike Fluid, raw-PHP templates apply no output escaping. Build a PHP array and echo json_encode($data, JSON_THROW_ON_ERROR) — never concatenate JSON strings from record data, that is a JSON injection the moment a value contains a quote. As everywhere in the headless pipeline, JSON encoding is not HTML safety: the consuming frontend still escapes on render.

headless.cookieDomainPerSite — derive the auth cookie domain per site (FE & BE middleware); see Multi-Site & URL Configuration.

headless.assetsCacheBusting — append the file mtime as a query string to processed-file URLs.

headless.prettyPrintJSON_PRETTY_PRINT on every response (debugging).

Older versions: the per-release availability matrix lives in Reference: Feature Flags. The only flag dropped between 4.x and 5.x is headless.redirectMiddlewares — the redirects integration is now auto-enabled when EXT:redirects is installed.

EXT:form 

Form integration — JSON form definitions, i18n, decorators, validators and the JsonRedirect finisher — is documented in EXT:form.

Content element categories 

The default TYPO3 Headless site set (friendsoftypo3/headless) does not render categories at all: lib.contentElement does not define a categories field (and therefore no content element carries one), and the page response does not contain a categories field either. This avoids one sys_category join per content element on every uncached render for projects that do not use categories.

If your project needs them, re-add the field in your site package TypoScript, loaded after the set. Point pidInList at the place your categories are actually stored — either the storage folder uid, or the current root page with recursive as shown below.

Content element categories:

lib.contentElement.fields.categories = COA
lib.contentElement.fields.categories {
    10 = CONTENT
    10 {
        table = sys_category
        select {
            pidInList.data = leveluid : 0
            recursive = 99
            selectFields = sys_category.title
            join = sys_category_record_mm on sys_category_record_mm.uid_local = sys_category.uid
            where {
                field = uid
                wrap = AND sys_category_record_mm.tablenames = 'tt_content' AND sys_category_record_mm.uid_foreign=|
            }
        }
        renderObj = TEXT
        renderObj {
            field = title
            wrap = |###BREAK###
        }
    }
    stdWrap.split {
        token = ###BREAK###
        cObjNum = 1 |*|2|*| 3
        1 {
            current = 1
            stdWrap.wrap = |
        }
        2 {
            current = 1
            stdWrap.wrap = ,|
        }
        3 {
            current = 1
            stdWrap.wrap = |
        }
    }
}
Copied!

Page categories — the legacy lib.categories definition is still shipped (the legacy and mixed sets load it; the default set does not). Import it, fix the storage pid the same way, and add the field back to the page response:

@import 'EXT:headless/Configuration/TypoScript/Legacy/Categories.typoscript'

lib.categories.10.select.pidInList >
lib.categories.10.select.pidInList.data = leveluid : 0
lib.categories.10.select.recursive = 99

page.10.fields.categories =< lib.categories
Copied!

Both snippets render a comma-separated string of category titles, matching the output of the legacy set — use them when the consuming frontend should not need any changes.

Alternative: categories as a JSON array 

If your frontend does not depend on the legacy string format, prefer structured output. This variant uses the EXT:headless DatabaseQueryProcessor and renders each category as an object, so the field becomes "categories": [{"id": 2, "title": "News"}, ...] instead of "News,Events":

lib.contentElement.fields.categories = JSON
lib.contentElement.fields.categories {
    dataProcessing {
        10 = FriendsOfTYPO3\Headless\DataProcessing\DatabaseQueryProcessor
        10 {
            table = sys_category
            pidInList.data = leveluid : 0
            recursive = 99
            join = sys_category_record_mm ON sys_category_record_mm.uid_local = sys_category.uid
            where.data = field:uid
            where.wrap = sys_category_record_mm.tablenames = 'tt_content' AND sys_category_record_mm.uid_foreign=|
            orderBy = sys_category.sorting
            as = categories
            fields {
                id = INT
                id {
                    field = uid
                }
                title = TEXT
                title {
                    field = title
                }
            }
        }
    }
}
Copied!

For page categories use the same definition with where.wrap = sys_category_record_mm.tablenames = 'pages' AND sys_category_record_mm.uid_foreign=| and assign it to the page response instead:

page.10.fields.categories = JSON
page.10.fields.categories {
    dataProcessing {
        # same processor configuration as above, with tablenames = 'pages'
    }
}
Copied!

Note this changes the shape of the categories field — frontends migrating from the legacy set must be updated accordingly.

Preview of hidden pages 

The frontend can preview hidden pages if backend cookies reach it. Since there are no cross-domain cookies, backend and frontend must share a root domain (e.g. api.domain.com / domain.com); set it as cookieDomain (note the leading dot):

$GLOBALS['TYPO3_CONF_VARS']['BE']['cookieDomain'] = '.domain.com';
Copied!

If the frontend forwards all backend cookies, previewing hidden content works. For multi-domain setups (api.domain1.com/domain1.com, api.domain2.com/domain2.com) use the headless.cookieDomainPerSite feature flag instead of a static cookieDomain.

Workspace preview 

Previewing workspace versions from the Workspaces module works out of the box — the JSON output renders the workspace overlay of the content, and backend preview URLs are rewritten to the frontend domain. The cookie requirements above apply here too. No feature flag is involved (in 4.x this ran through workspace XClasses, likewise enabled automatically).

Backend preview of mixed-mode sites 

Backend-initiated page previews of a headless: 2 site render HTML by default. To preview JSON instead, set in the site's settings.yaml:

headless:
  preview:
    overrideMode: 1
Copied!

The value replaces the site's mode for backend previews only (0 HTML — the default, 1 JSON). Sites with headless: 0 or headless: 1 are not affected.

XML sitemap 

Since 4.0 the XML sitemap is plain core EXT:seo — headless only ships the rendering templates. If URLs in the sitemap index (/sitemap.xml) point at the API host instead of the frontend, set frontendApiProxy in the site's config.yaml (the field shows up in the backend site module only with headless.storageProxy enabled — editing the YAML directly always works), or point the sitemap at frontendBase via settings.yaml:

headless:
  sitemap:
    key: frontendBase
Copied!

The sitemap-index links are detected by their page type. If your sitemap uses a custom typeNum, also set it (default 1533906435) — otherwise the index links are not rewritten at all:

headless:
  sitemap:
    type: '2400000000'
Copied!

Multi-Site & URL Configuration 

The headless setup typically has two domains — one for the API (TYPO3 backend) and one for the frontend app. This page is the single source of truth for how EXT:headless rewrites URLs, manages cookies and routes assets across sites.

Glossary 

Key Meaning
base Public URL of the TYPO3 site (the API).
frontendBase Public URL of your SPA / frontend app. Used by UrlUtility to rewrite links in the JSON response.
frontendApiProxy Public URL of the API as seen by browsers going through the frontend's reverse proxy (e.g. https://example.com/headless).
frontendFileApi Public URL for processed files (images, PDFs). Used together with the headless.storageProxy feature flag.
cookieDomain Domain to scope session cookies to. Needed when API and frontend share a root domain.
baseVariants Per-environment overrides of any of the above, gated by an expression-language condition.

Single-domain setup 

API and frontend both on the same host. No URL rewriting needed.

# config/sites/<identifier>/config.yaml
rootPageId: 1
base: https://example.com
headless: 1
Copied!

Two-domain setup (typical) 

API on api.example.com, frontend on example.com. Set frontendBase so URLs in the JSON response point at the frontend.

rootPageId: 1
base: https://api.example.com
headless: 1
frontendBase: https://example.com
# Optional, used with the headless.storageProxy feature flag:
# processed-file URLs are served through these proxy paths
# (page/typolink URLs always use frontendBase):
frontendApiProxy: https://example.com/headless
frontendFileApi:  https://example.com/headless/fileadmin
Copied!

Now any typolink or page URL in the JSON response is rewritten from https://api.example.com/... to https://example.com/....

Multi-language overrides 

Each language can override the URL keys independently:

languages:
  - languageId: 0
    title: English
    base: /
    locale: en_US.UTF-8
    frontendBase: https://example.com
  - languageId: 1
    title: Deutsch
    base: /de/
    locale: de_DE.UTF-8
    frontendBase: https://example.de
Copied!

Per-environment overrides (baseVariants) 

Same site, different dev/stage/prod values:

baseVariants:
  - base: https://api.dev.example.com
    condition: 'applicationContext == "Development"'
    frontendBase: https://dev.example.com
    frontendApiProxy: https://dev.example.com/headless
  - base: https://api.stage.example.com
    condition: 'applicationContext == "Staging"'
    frontendBase: https://stage.example.com
    frontendApiProxy: https://stage.example.com/headless
Copied!

condition is evaluated by TYPO3CMSCoreExpressionLanguageResolver with the site scope — available are the default variables (applicationContext, typo3, date, features) and functions like getenv("…"); request is not available here.

The first variant whose condition matches wins, and its values are read verbatim: a key missing from the matching variant resolves to an empty string, it does not fall back to the site-level value — repeat every key in every variant.

Shared root domain & cookies 

When api.example.com and example.com share a root domain (example.com), the browser can carry session cookies between them only if the cookie's Domain attribute is set to the shared root.

Option A — set globally (simple, single-site instance):

// config/system/additional.php
$GLOBALS['TYPO3_CONF_VARS']['BE']['cookieDomain'] = '.example.com';
Copied!

Option B — per-site (multi-domain instance): enable headless.cookieDomainPerSite and put cookieDomain in the site config. The CookieDomainPerSite middleware looks up the site by exact request host and injects its cookieDomain for the duration of the request.

$GLOBALS['TYPO3_CONF_VARS']['SYS']['features']['headless.cookieDomainPerSite'] = true;
Copied!
baseVariants:
  - base: https://api.example.com
    condition: 'applicationContext == "Production"'
    cookieDomain: .example.com
Copied!

Hidden-page preview 

Preview of hidden pages relies on the same cookie flow as above. As long as the frontend forwards all cookies to the API request, the backend user's preview cookie reaches TYPO3 and hidden pages render.

XML sitemap URLs 

The links in the sitemap index (the t3://page?uid=current&type=… sitemap-type links) resolve through frontendApiProxy; all other page links use frontendBase. To make the index links use frontendBase too, set:

# config/sites/<identifier>/settings.yaml
headless:
  sitemap:
    key: frontendBase
Copied!

Index links are matched by the sitemap page type. With a custom sitemap typeNum, set headless.sitemap.type (default 1533906435) as well, or the index links keep pointing at the API host:

headless:
  sitemap:
    type: '2400000000'
Copied!

Storage proxy (asset routing) 

headless.storageProxy plus frontendFileApi in the site config makes processed-file URLs (images, PDFs) point at the frontend's proxy instead of the TYPO3 fileadmin. Useful when you want the browser to fetch assets from the same origin as the SPA.

$GLOBALS['TYPO3_CONF_VARS']['SYS']['features']['headless.storageProxy'] = true;
Copied!

Developer 

How to shape and extend the JSON output. Each topic has its own page:

  • cObjects — the headless TypoScript content objects: JSON, CONTENT_JSON, plus the casting objects BOOL/INT/FLOAT.
  • Custom content — custom content elements, internal Extbase plugins, integrating external plugins (EXT:news et al.), custom TypoScript objects and meta-data overrides.
  • Data processorsDatabaseQueryProcessor, FilesProcessor, MenuProcessor, GalleryProcessor and friends, with their headless-specific options.
  • Events — PSR-14 extension points and how to register listeners; full list in Reference: Events.
  • Images & files — file/image payloads and processing configuration (the storage proxy itself is a feature flag, see Configuration).
  • Snippets — drop-in recipes for common tasks.

Form output decorators are documented with the rest of the form integration in EXT:form; the default response shape and every shipped lib.* object in Reference: TypoScript helpers.

TypoScript cObjects 

EXT:headless registers a handful of new cObjects:

  • JSON
  • CONTENT_JSON
  • BOOL, FLOAT, INT

BOOL, FLOAT and INT take value and value. (stdWrap applied to the value); every remaining top-level property is treated as stdWrap configuration directly — noIndex.field = no_index works, there is no stdWrap. sub-key like core TEXT. They return a real bool / float / int and only work as fields inside a JSON cObject, not in generic TypoScript.

JSON 

Builds a JSON object inline.

lib.meta = JSON
lib.meta {
  if.isTrue = 1
  fields {
    title = TEXT
    title {
      field = seo_title
      stdWrap.ifEmpty.cObject = TEXT
      stdWrap.ifEmpty.cObject {
        field = title
      }
    }
    robots {
      fields {
        noIndex = BOOL
        noIndex.field = no_index
      }
    }
    ogImage = TEXT
    ogImage {
      dataProcessing {
        10 = FriendsOfTYPO3\Headless\DataProcessing\FilesProcessor
        10 {
          as = media
          references.fieldName = og_image
          processingConfiguration {
            returnFlattenObject = 1
          }
        }
      }
    }
  }
  dataProcessing {
  }
  stdWrap {
  }
}
Copied!

The JSON cObject understands these properties:

`if` — render the object only when the condition is met.

`fields` — array of child cObjects. Each field accepts:

  • intval / floatval / boolval — cast the result.
  • ifEmptyReturnNull — return null when the result is empty.
  • ifEmptyUnsetKey — drop the key when the result is empty.
  • source — nested field blocks only: output the block's fields result under a different key. Ignored on plain fields, and ignored when the block defines dataProcessing — that result is always stored under the block's own key.
  • dataProcessing — run data processors (see lib.meta.ogImage).

`nullableFieldsIfEmpty` — comma list of field names to null out when empty (bulk variant of ifEmptyReturnNull).

A field whose cObject is USER_INT (or whose output starts with an <!--INT_SCRIPT placeholder) is wrapped in HeadlessUserInt markers, so the uncacheable value is substituted into the JSON on output; with ifEmptyReturnNull = 1 the nullable marker variant is used.

`dataProcessing`replaces the fields output: the processors run and the value registered under the last as key becomes the object's content (e.g. MenuProcessor). Set dataProcessingMerge to keep both.

`dataProcessingMerge` — merge instead of replace. With dataProcessingMerge = 1 and fields present, the fields output is kept and every processor result is added to it under the processor's target (as) name; on a key collision the processor result wins.

lib.page = JSON
lib.page {
  dataProcessingMerge = 1
  fields {
    title = TEXT
    title.field = title
  }
  dataProcessing {
    10 = FriendsOfTYPO3\Headless\DataProcessing\MenuProcessor
    10.as = mainMenu
  }
}

# {"title":"…","mainMenu":[…]} instead of the menu replacing the title
Copied!

The flag also works on a nested field block that defines both fields and dataProcessing. Without the flag — or without fields — the behaviour is unchanged: the processors replace the whole object.

`stdWrap`stdWrap applied to the already-encoded JSON string.

CONTENT_JSON 

Like core's CONTENT, but content elements are grouped by colPos and JSON-encoded by default. All CONTENT options apply, plus four JSON-specific extras:

merge

Run a second CONTENT_JSON query and merge the result into the first — handy for the slide feature.

lib.content = CONTENT_JSON
lib.content {
  table = tt_content
  select {
    orderBy = sorting
    where = {#colPos} != 1
  }
  merge {
    table = tt_content
    select {
      orderBy = sorting
      where = {#colPos} = 1
    }
    slide = -1
  }
}
Copied!

doNotGroupByColPos

0 (default) groups by colPos — every rendered element must then expose a colPos field, otherwise rendering throws a RuntimeException. 1 returns a flat JSON array.

An empty result is encoded as {} with grouping enabled and as [] with doNotGroupByColPos = 1 — consumers must handle both types.

lib.content = CONTENT_JSON
lib.content {
  table = tt_content
  select {
    orderBy = sorting
    where = {#colPos} != 1
  }
  doNotGroupByColPos = 1
}
Copied!

sortByBackendLayout

Order the colPos groups by the order of columns in the page's backend layout instead of numerically.

returnSingleRow

Return the first matched element as a single object instead of an array — for one-record queries. Only takes effect together with doNotGroupByColPos = 1; with the default colPos grouping the flag is silently ignored.

lib.header = CONTENT_JSON
lib.header {
  table = tt_content
  select {
    orderBy = sorting
    max = 1
  }
  doNotGroupByColPos = 1
  returnSingleRow = 1
}
Copied!

Custom content & plugin output 

How to put your own data into the JSON response. Three flavours, ordered by what's most common.

Custom content elements 

Standard TYPO3 procedure — register a CE via TCA and add a frontend template. For headless, the frontend template is TypoScript over lib.contentElement (or lib.contentElementWithHeader when you want the standard header block).

tt_content.demo >
tt_content.demo =< lib.contentElementWithHeader
tt_content.demo {
  fields {
    content {
      fields {
        demoField = TEXT
        demoField.value = This is a demo content-element
        bodytext = TEXT
        bodytext {
          field = bodytext
          parseFunc =< lib.parseFunc_RTE
        }
        demoSubfields {
          fields {
            demoSubfield = TEXT
            demoSubfield.value = Nested field
          }
        }
      }
    }
  }
}
Copied!

fields can be nested to any depth — that's your JSON shape. Use dataProcessing here just like in any other CE.

Internal Extbase plugins 

Modern Extbase plugins register as content elements (CType) rather than via the legacy list_type mechanism (deprecated in TYPO3 v13.4, removed in v14). Registration is the standard v14 pattern; only the frontend template is headless-specific.

1. Register the plugin in ext_localconf.php:

\TYPO3\CMS\Extbase\Utility\ExtensionUtility::configurePlugin(
    'MyExt',
    'DemoPlugin',
    [\Vendor\MyExt\Controller\DemoController::class => 'index'],
    // non-cacheable actions, if any:
    [\Vendor\MyExt\Controller\DemoController::class => ''],
);
Copied!

2. Make it pickable in the BE via Configuration/TCA/Overrides/tt_content.php:

\TYPO3\CMS\Extbase\Utility\ExtensionUtility::registerPlugin(
    'MyExt',
    'DemoPlugin',
    'Demo Plugin',
    'extension-myext-demo',
);
Copied!

The plugin now shows up as a CType with signature myext_demoplugin (extension key + lowercased plugin name).

3. Return JSON from the controller:

final class DemoController extends \TYPO3\CMS\Extbase\Mvc\Controller\ActionController
{
    public function indexAction(): ResponseInterface
    {
        return $this->jsonResponse(json_encode([
            'foo' => 'bar',
            'settings' => $this->settings,
        ], JSON_THROW_ON_ERROR));
    }
}
Copied!

4. Wire the headless TypoScript per CType. TYPO3 ships an EXTBASEPLUGIN cObject (since v12.3) — the modern, two-line replacement for the old USER + Bootstrap->run form:

tt_content.myext_demoplugin =< lib.contentElementWithHeader
tt_content.myext_demoplugin {
  fields {
    content {
      fields {
        data = EXTBASEPLUGIN
        data {
          extensionName = MyExt
          pluginName = DemoPlugin
          settings {
            test = TEXT
            test.value = The demo is working
          }
        }
      }
    }
  }
}
Copied!

The USER cObject form still works for back-compat, but the EXTBASEPLUGIN cObject is the canonical pattern (it's what ExtensionUtility::configurePlugin auto-generates internally). vendorName is obsolete either way — plugin registration works on FQCN controllers, extensionName + pluginName suffice. controller is only needed if a plugin exposes multiple controllers; otherwise the one passed to configurePlugin is used.

After a cache flush the JSON appears under the matching content element:

{
  "content": {
    "colPos0": [{
      "id": 12,
      "type": "myext_demoplugin",
      "colPos": 0,
      "appearance": { "...": "..." },
      "content": {
        "data": {
          "foo": "bar",
          "test": "The demo is working"
        }
      }
    }]
  }
}
Copied!

External plugins (EXT:news etc.) 

Wire the foreign plugin's CType into tt_content.<ctype> and point its templates at your own JSON Fluid templates. The CType signature is whatever the foreign extension registers — for EXT:news it's news_pi1.

tt_content.news_pi1 =< lib.contentElementWithHeader
tt_content.news_pi1 {
  fields {
    content {
      fields {
        data = EXTBASEPLUGIN
        data {
          extensionName = News
          pluginName = Pi1
          view < plugin.tx_news.view
          persistence < plugin.tx_news.persistence
          settings < plugin.tx_news.settings
        }
      }
    }
  }
}
Copied!

Then override the plugin's template paths so they point at your JSON templates:

plugin.tx_news {
  view {
    templateRootPath = EXT:headless_news/Resources/Private/News/Templates/
    partialRootPath  = EXT:headless_news/Resources/Private/News/Partials/
    layoutRootPath   = EXT:headless_news/Resources/Private/News/Layouts/
  }
}
Copied!

The templates produce JSON instead of HTML. There's no enforced structure — design it for your frontend. Example List.html:

<f:spaceless>
  {"list": [<f:for each="{news}" as="newsItem" iteration="i">
    <f:render section="NewsListView" arguments="{newsItem: newsItem}"/>
    {f:if(condition: i.isLast, else: ',')}
  </f:for>]}
</f:spaceless>
Copied!

See headless_news for a complete worked example.

Adding fields via raw TypoScript 

Sometimes a CONTENT (or any other cObject) needs to land inside the JSON output without being a CE — e.g. a list of related records on every page. The trick is to make TYPO3's text output valid JSON through stdWrap.split:

page.10.fields {
  related = CONTENT
  related {
      table = tx_myextension_domain_model_things
      select {
        pidInList = this
      }
      renderObj = JSON
      renderObj {
        fields {
          title = TEXT
          title.field = title
          link = TEXT
          link.typolink.parameter.field = uid
          link.typolink.returnLast = url
        }
        stdWrap.wrap = |###BREAK###
      }
      stdWrap {
        innerWrap = [|]
        split {
          token = ###BREAK###
          cObjNum = 1 |*|2|*| 3
          1 { current = 1
              stdWrap.wrap = | }
          2 < .1
          2.stdWrap.wrap = ,|
          3 < .1
        }
      }
  }
}
Copied!

For most cases the much simpler DatabaseQueryProcessor is a better fit.

Overriding the meta object (legacy set) 

lib.meta exists only in the legacy (4.x-compatible) response — on the default set the seo object is populated by the MetaHandler from page properties instead. On the legacy set, replace lib.meta wholesale on a specific route (e.g. news detail):

lib.meta.stdWrap.override.cObject = JSON
lib.meta.stdWrap.override.cObject {
  if.isTrue.data = GP:tx_news_pi1|news
  dataProcessing.10 = FriendsOfTYPO3\Headless\DataProcessing\DatabaseQueryProcessor
  dataProcessing.10 {
    table = tx_news_domain_model_news
    uidInList.data = GP:tx_news_pi1|news
    uidInList.intval = 1
    pidInList = 0
    max = 1
    as = records
    fields < lib.meta.fields
    fields {
      title = TEXT
      title.field = title
      subtitle = TEXT
      subtitle.field = teaser
      description = TEXT
      description.field = bodytext
    }
    returnFlattenObject = 1
  }
}
Copied!

Data Processors 

Common behaviour 

`appendData` — the menu, language-menu, gallery and files processors share a destructive default: with appendData absent or 0 the processor removes data from the processed result and strips data from every item in the target (as) array — for menus recursively through all children. Set appendData = 1 to keep the raw record data.

`as` defaults — when as is omitted, each processor falls back to its default target name:

Processor Default as
MenuProcessor menu
LanguageMenuProcessor languagemenu
GalleryProcessor gallery
FilesProcessor media
DatabaseQueryProcessor records
RootSitesProcessor sites

DatabaseQueryProcessor 

It's the EXT:headless equivalent of TYPO3's own DatabaseQueryProcessor.

10 = FriendsOfTYPO3\Headless\DataProcessing\DatabaseQueryProcessor
10 {
  table = tt_content
  pidInList = 123
  as = contents
  fields {
    header = TEXT
    header {
      field = header
    }
    bodytext = TEXT
    bodytext {
      field = bodytext
      parseFunc =< lib.parseFunc_RTE
    }
  }
}
Copied!

Apart from the properties of TYPO3's DatabaseQueryProcessor (if, table, as and dataProcessing) it provides the following properties:

  • fields — JSON-style field map applied to every row
  • overrideFields — same, merged over already-processed rows
  • returnFlattenObject (Default: 0) — return the single row directly instead of an array

fields and overrideFields are rendered through the JSON cObject, so every per-field option documented there (intval, ifEmptyReturnNull, nested fields, dataProcessing, …) works inside them.

ExtractPropertyProcessor 

Extract a single (maybe nested) property from a given array.

Both as and key are required — the processor throws an exception when either is missing. Its result replaces the entire processed data: only [as => value] survives, everything else the previous processors produced is discarded.

Example see below in section on FilesProcessor.

FilesProcessor 

lib.meta.fields.ogImage = TEXT
lib.meta.fields.ogImage {
  dataProcessing {
    # Use the column 'og_image' to render an array with all relevant
    # information (such as the publicUrl)
    10 = FriendsOfTYPO3\Headless\DataProcessing\FilesProcessor
    10.as = media
    10.references.fieldName = og_image
    10.processingConfiguration.returnFlattenObject = 1

    # Extract only property 'publicUrl' from the above created array
    20 = FriendsOfTYPO3\Headless\DataProcessing\ExtractPropertyProcessor
    20.key = media.publicUrl
    20.as = media
  }
}
Copied!

Sources besides references.fieldName: references (list of sys_file_reference uids, optionally references.table), files (sys_file uids), collections (file collection uids), folders (combined-identifier folder paths, folders.recursive = 1 to descend). Further options: sorting (file property, sorting.direction), appendData (1 keeps the processed record data alongside the files; absent or 0 — the default — strips data from the result, see Common behaviour) and the processingConfiguration block described in Image rendering. Default as is media.

FlexFormProcessor 

This DataProcessor allows to process a flexform field such as tt_content.pi_flexform and optionally override its property values.

fieldName defaults to pi_flexform. When as is omitted, the processed flexform is written back in place — into data.<fieldName>, or the top-level <fieldName> key when the value lives there.

10 = FriendsOfTYPO3\Headless\DataProcessing\FlexFormProcessor
10 {
  fieldName = pi_flexform
  as = flexform
  overrideFields {
    fieldA = TEXT
    fieldA {
      value = 123
    }
  }
}
Copied!

GalleryProcessor 

See Images GalleryProcessor. Supports appendData (see Common behaviour); default as is gallery.

LanguageMenuProcessor 

It's the EXT:headless equivalent of TYPO3's LanguageMenuProcessor — same output enriched for JSON consumption, with the raw data stripped by default. Allowed options: if, languages, as (default languagemenu), addQueryString and appendData (see Common behaviour). Unknown configuration keys throw an exception.

lib.languageMenu = JSON
lib.languageMenu {
  dataProcessing {
    10 = headless-language-menu
    10.as = languageMenu
  }
}
Copied!

RootSitesProcessor 

10 = FriendsOfTYPO3\Headless\DataProcessing\RootSitesProcessor
10 {
   as = sites
   # allow to override provider of data for output processor, if empty defaults to FriendsOfTYPO3\Headless\DataProcessing\RootSiteProcessing\SiteProvider
   # your-class implementing FriendsOfTYPO3\Headless\DataProcessing\RootSiteProcessing\SiteProviderInterface
   # example value: Vendor\Project\RootSiteProcessing\CustomSiteProvider
   siteProvider =
   # allow to override output of processor, if empty defaults to FriendsOfTYPO3\Headless\DataProcessing\RootSiteProcessing\SiteSchema
   # your-class implementing FriendsOfTYPO3\Headless\DataProcessing\RootSiteProcessing\SiteSchemaInterface
   # example value: Vendor\Project\RootSiteProcessing\CustomSiteSchema
   siteSchema =
   # provider configuration, if empty defaults to 'sorting' field from pages table
   # example value = custom_sorting
   sortingField =
   # if empty defaults to sort by "sorting" field from `pages` table
   # your-class implementing FriendsOfTYPO3\Headless\DataProcessing\RootSiteProcessing\SiteSortingInterface
   # example value: Vendor\Project\RootSiteProcessing\CustomSorting
   sortingImplementation =
   # list of uid of root pages should be returned, i.e. you have 5 root pages(1,2,3,4,5), but two (4,5) of not ready to display, so you can hide it
   # example value = 1,2,3
   allowedSites =
   # automatically fetch root sites from another page/separator and filter sites yaml configs by returned list from database
   # very useful when you have multi site setup in one instance.
   # example value = 1
   sitesFromPid =
   # if empty defaults to uid,title,sorting - list of columns to fetch from database and provided for SiteSchema/DomainSchema to use
   # example value = uid,title,sorting
   dbColumns =
   # if empty defaults to "title" field from pages table, get site name from database
   # example value = your-custom-field-from-pages-table
   titleField =
}
Copied!

Events you can listen to 

EXT:headless dispatches PSR-14 events where adjusting the JSON output is most useful. For the full signature of each event class see Reference: Events.

  • FriendsOfTYPO3HeadlessEventEnrichFileDataEvent — fired in FileUtility::process() after a file's default properties have been collected, before crop-variants and autogenerate run. Use it to add custom fields (focus point, alt text from another source, signed CDN URL) to every file in the response. Two caveats: process() recurses for crop variants and autogenerate derivatives, so the event fires for each of them too; and a configured properties.includeOnly filter is applied after the event — list your custom keys there or they are dropped.
  • FriendsOfTYPO3HeadlessEventFileDataAfterCropVariantProcessingEvent — fired once per file after all crop variants are processed (also when there are none). Annotate or rewrite the complete file payload including cropVariants.

For customising the JSON redirect envelope, listen to the core TYPO3CMSRedirectsEventRedirectWasHitEvent — see EXT:redirects.

Listener registration 

The #[AsEventListener] attribute is the idiomatic TYPO3 v14 form — no YAML needed:

<?php

declare(strict_types=1);

namespace Vendor\MyExt\EventListener;

use FriendsOfTYPO3\Headless\Event\EnrichFileDataEvent;
use TYPO3\CMS\Core\Attribute\AsEventListener;
use Vendor\MyExt\Service\CdnSigner;

#[AsEventListener(identifier: 'myext/file/signed-cdn-url')]
final readonly class AddSignedCdnUrl
{
    public function __construct(private CdnSigner $cdn) {}

    public function __invoke(EnrichFileDataEvent $event): void
    {
        $props = $event->getProperties();
        $props['signedUrl'] = $this->cdn->sign($props['publicUrl']);
        $event->setProperties($props);
    }
}
Copied!

Alternatively, register the listener via a Services.yaml tag:

# Configuration/Services.yaml
services:
  Vendor\MyExt\EventListener\AddSignedCdnUrl:
    tags:
      - name: event.listener
        identifier: 'myext/file/signed-cdn-url'
        event: FriendsOfTYPO3\Headless\Event\EnrichFileDataEvent
Copied!

After adding or re-registering a listener, run bin/typo3 cache:flush — the container is compiled.

Image rendering 

FileUtility 

The file rendering in EXT:headless is handled by FileUtility which renders the following JSON for each file:

"publicUrl": "https://www.example.org/fileadmin/_processed_/6/c/csm_my-image_51125112.jpg"
"properties": {
  "mimeType": "image/jpeg",
  "type": "image",
  "filename": "csm_my-image_51125112.jpg",
  "link": null,
  "linkData": null,
  "originalUrl": "https://www.example.org/fileadmin/my-image.jpg",
  "uidLocal": "123",
  "fileReferenceUid": "234",
  "size": "50 KB",
  "title": null,
  "alternative": null,
  "description": null,
  "dimensions": {
    "width": "300",
    "height": "100",
  },
  "cropDimensions": {
    "width": "300",
    "height": "100",
  },
  "crop": { ... },
  "autoplay": null,
  "extension": null
}
Copied!

The file rendering can be simplified via legacyReturn = 0 processing configuration flag

lib.meta {
  fields {
    ogImage = TEXT
    ogImage {
      dataProcessing {
        10 = FriendsOfTYPO3\Headless\DataProcessing\FilesProcessor
        10 {
          as = media
          references.fieldName = og_image
          processingConfiguration {
            legacyReturn = 0
          }
        }
      }
    }
  }
}
Copied!

Will output:

"url": "https://www.example.org/fileadmin/_processed_/6/c/csm_my-image_51125112.jpg"
"mimeType": "image/jpeg",
"type": "image",
"filename": "csm_my-image_51125112.jpg",
"originalUrl": "https://www.example.org/fileadmin/my-image.jpg",
"link": null,
"uidLocal": "123",
"fileReferenceUid": "234",
"size": "50 KB",
"title": null,
"alternative": null,
"description": null,
"dimensions": {
  "width": "300",
  "height": "100",
},
"cropDimensions": {
  "width": "300",
  "height": "100",
},
"cropVariants": { "default": "..." },
"autoplay": null,
"extension": null
Copied!

cropVariants only appears for images whose crop configuration defines more than one crop variant.

EnrichFileDataEvent 

FileUtility is emitting the event EnrichFileDataEvent for manipulating the properties array.

To add a listener add this to your Configuration/Services.yaml:

My\Extension\EventListener\TweakFileData:
  tags:
    - name: event.listener
      identifier: 'tweak-file-data'
      event: FriendsOfTYPO3\Headless\Event\EnrichFileDataEvent
Copied!

FilesProcessor 

headless provides its own FilesProcessor to render files.

Here's an example of how the og_image of a page is being rendered via TypoScript:

lib.meta {
  fields {
    ogImage = TEXT
    ogImage {
      dataProcessing {
        10 = FriendsOfTYPO3\Headless\DataProcessing\FilesProcessor
        10 {
          as = media
          references.fieldName = og_image
          processingConfiguration {
            returnFlattenObject = 1
          }
        }
      }
    }
  }
}
Copied!

Configuration 

The rendering configuration can be set via the property processingConfiguration and provides the following sub-properties:

  • legacyReturn (0|1): Allows to control new simplified output or old system (old system by default)
  • linkResult (0|1): Allows to define if file object should return only url of defined link or whole LinkResult object
  • cacheBusting (0|1): Allows to enable cacheBusting urls for processed files (enabled globally by the headless.assetsCacheBusting feature flag)
  • width, height, minWidth, minHeight, maxWidth, maxHeight: image processing instructions passed to the core processor
  • cropVariant (name): render a specific crop variant (default default)
  • processGif (0|1): enable optional processing of gif files (default off)
  • outputCropArea (0|1): include the resolved crop area in the output
  • conditionalCropVariant (0|1): Allows conditionally autogenerate files with defined variants if set (if not all variants are returned)
  • processPdfAsImage (0|1): Enabled optional processing pdf files as image (default off)
  • processSvg (0|1): Enabled optional processing svg files (default off)
  • properties.byType (0|1): Allows filter file properties by type (i.e. do not return video properties on images)
  • properties.defaultFieldsByType (coma separated list of fields): Default fields for when enabled option properties.byType
  • properties.defaultImageFields (coma separated list of fields): Default fields for image type when enabled option properties.byType
  • properties.defaultVideoFields (coma separated list of fields): Default fields for video type when enabled option properties.byType
  • properties.includeOnly (string, comma separated): Configure what file properties to return
  • properties.flatten (0|1): Flatten nested properties (dimensions array) to use with properties.includeOnly
  • returnFlattenObject: without that flag an array of (multiple) images is rendered. Set this if you're only rendering 1 image and want to reduce nesting.
  • delayProcessing: can be used to skip processing of images (and have them simply collected with the FilesProcessor), in order to have them processed by the next processor in line (which is generally GalleryProcessor).
  • fileExtension: can be used to convert the images to any desired format, e.g. webp.
  • autogenerate: `retina2x`: set this to render an additional image URI in high quality (200%). lqip: set this to render an additional image URI with low quality (10%). * also custom defined size & file formats see example below
10 = FriendsOfTYPO3\Headless\DataProcessing\FilesProcessor
10 {
   ...
   processingConfiguration {
      delayProcessing = 1
   }
}
20 = FriendsOfTYPO3\Headless\DataProcessing\GalleryProcessor
20 {
   ...
   autogenerate {
      retina2x = 1
      customFileWebp {
         fileExtension = webp
         factor = 1.0
      }
      customTinyJpg {
         fileExtension = jpg
         factor = 0.2
      }
   }
}
Copied!
10 = FriendsOfTYPO3\Headless\DataProcessing\FilesProcessor
10 {
   ...
   processingConfiguration {
      # (1 by default = legacy output; set 0 for the new simplified format)
      legacyReturn = 0
      # Return whole LinkResult object instead simple url
      linkResult = 1
      # check if we need to conditionally check if we should generate crop variants
      conditionalCropVariant = 1
      # Generate cacheBusting urls for images and video files
      cacheBusting = 1
      properties {
         # return props by mimeType
         byType = 1
         # return only properties defined below
         includeOnly = alternative,width,height
         # you can also alias fields
         # includeOnly = alternative as alt,width,height
         # with includeOnly you can use option `flatten` to flatten dimensions array
         flatten = 1
      }
   }
}
Copied!

GalleryProcessor 

Configuration 

The rendering configuration is set directly on the processor: all options of the processingConfiguration block documented above (legacyReturn, fileExtension, cropVariant, …) are read from the processor's top-level configuration — there is no processingConfiguration. sub-key.

Default as is gallery. The processor also supports appendData (1 keeps the raw record data; absent or 0 — the default — strips data from the result) and cropVariant to select the crop variant used for the width/height calculation (default default).

  • maxGalleryWidth: set to the core constant {$styles.content.textmedia.maxW}
  • maxGalleryWidthInText: set to the core constant {$styles.content.textmedia.maxWInText}
  • columnSpacing: set to the core constant {$styles.content.textmedia.columnSpacing}
  • borderWidth: set to the core constant {$styles.content.textmedia.borderWidth}
  • borderPadding: set to the core constant {$styles.content.textmedia.borderPadding}
  • autogenerate `retina2x` lqip * custom keys with factor / fileExtension — same syntax as in the FilesProcessor example above

Snippets 

Drop-in recipes for the most common questions.

Customise file output (URL signing, focus point, etc.) 

Listen to EnrichFileDataEvent. See Events you can listen to.

Override the JSON form output 

Implement FriendsOfTYPO3HeadlessFormDecoratorDefinitionDecoratorInterface or extend AbstractFormDefinitionDecorator, then point the form's renderingOptions.formDecorator at it. See EXT:form.

Add a JSON field to every page response 

Extend the page object in TypoScript (loaded after the set):

page.10.fields {
  myField = TEXT
  myField.value = static value
  myDynamic = TEXT
  myDynamic.data = TSFE:id
}
Copied!

Read the current site's frontend URL 

Inject FriendsOfTYPO3HeadlessUtilityHeadlessFrontendUrlInterface and call withRequest($request)->getFrontendUrl(). Returns the configured frontendBase for the active site and language.

Detect headless mode in your own code 

Inject FriendsOfTYPO3HeadlessUtilityHeadlessModeInterface and call isEnabledFor($request). FULL mode always returns true; MIXED mode only when the request's first Accept header value is exactly application/json (the strict match is the API contract).

Encode JSON safely from your own code 

Inject FriendsOfTYPO3HeadlessJsonJsonEncoderInterface instead of calling json_encode directly. It applies HTML-attribute-safe hex flags (JSON_HEX_APOS | JSON_HEX_AMP) and the headless.prettyPrint feature flag for free. Note that it does not throw on encoding failures — a JsonException is caught, logged as critical, and the string "[]" is returned instead.

Decode nested JSON inside an array 

Inject FriendsOfTYPO3HeadlessJsonJsonDecoderInterface and call decode($array). Any string value that looks like JSON gets decoded (nested structures come back as stdClass objects, the outer array stays an array). Useful when stitching multiple JSON cObjects together.

Process a file with the same shape as the default response 

Inject FriendsOfTYPO3HeadlessUtilityFileUtilityInterface and call process($fileReference, ProcessingConfiguration::fromOptions($opts)). The output matches what content elements receive — your custom endpoints stay shape-compatible with the rest of the JSON API.

EXT:form 

If EXT:form is enabled in the TYPO3 instance, EXT:headless produces JSON form definitions instead of HTML. Forms designed in the form editor work out of the box; this page documents the headless-specific hooks that help frontend developers.

Configuration (YAML) 

All options live in the form's YAML configuration file.

i18n strings 

Add translated UI strings (button labels, help messages) directly in the form root. They land in the response's i18n section.

i18n:
  identifier: 'i18n'
  properties:
    someButtonLabel: 'Submit or Cancel'
    someHelpMessage: 'You need to fill out this form'
    requiredFields: 'These fields are required'
Copied!

Strings are translated through the standard TYPO3 XLF pipeline. Translation keys resolve against the form's original identifier (kept in renderingOptions._originalIdentifier), not the runtime identifier that headless suffixes with the content element uid — key your XLF entries on the identifier from the form YAML. A forced locale can be passed to FormTranslationService::translateElementValue() as its optional fourth parameter.

On key collision, i18n.properties entries win over the renderingOptions.submitButtonLabel shortcut — both end up in the response's i18n object, YAML properties last.

Form decorator 

Headless ships FormDefinitionDecorator as the default. Override per form via renderingOptions.formDecorator:

renderingOptions:
  formDecorator: Your\Vendor\Form\CustomDefinitionDecorator
Copied!

See Customising the JSON output (decorators) below for writing your own decorator.

Rich-text fields (TYPO3 v14.2+) 

TYPO3 v14.2 allows RTE content in form element labels and StaticText elements. To ship that HTML safely in the JSON definition, opt in per form to the shipped decorator:

renderingOptions:
  formDecorator: FriendsOfTYPO3\Headless\Form\Decorator\RichTextFormDefinitionDecorator
Copied!

HTML-carrying labels and static texts are then run through lib.parseFunc_RTE (resolving t3:// links), falling back to lib.parseFunc_links and finally to the plain HTML sanitizer when neither lib exists, and flagged with labelFormat/textFormat: html so the frontend knows to render them as markup. The decorator also processes api.actionAfterSuccess.message the same way and flags it with messageFormat: html.

Validator error codes 

Per validator, set errorMessage to a TYPO3 XLF error code — the default form translation files resolve it:

validators:
  - identifier: 'NotEmpty'
    errorMessage: 1221560910
Copied!

For RegularExpression validators, PHP and JS regex flavours differ. Provide a JS-flavoured fallback via FERegularExpression:

validators:
  - identifier: 'RegularExpression'
    options:
      regularExpression: '/^[a-z]+$/'
    FERegularExpression:
      expression: '^[a-z]+$'
      flags: 'i'
    errorMessage: 1221565130
Copied!

When the headless decorator sees FERegularExpression, it replaces options.regularExpression with that value as-is in the JSON response — the {expression, flags} pair maps directly to JavaScript's new RegExp(expression, flags).

Custom options (dynamic select/radio/checkbox) 

Implement FriendsOfTYPO3HeadlessFormCustomOptionsInterface and point the field at it:

- type: 'SingleSelectWithCountryList'
  identifier: 'country'
  label: 'Country'
  properties:
    customOptions: 'Your\Vendor\Form\CountryOptions'
Copied!

CountryOptions::get() is called per render and its return value replaces the field's options. Although the interface only declares get(): array, implementations are instantiated with four constructor arguments — ($field, $formFields, $identifier, $formRuntime): the field's definition array, all fields of the current page, the runtime form identifier and the FormRuntime — so options can depend on the surrounding form state.

If your custom form type isn't a standard one (so the frontend wouldn't know what to render), override the type sent to the frontend with FEOverrideType:

type: 'SingleSelectWithCountryList'
renderingOptions:
  FEOverrideType: 'Select'
Copied!

JSON redirect finisher 

The standard RedirectFinisher issues a real HTTP redirect. In headless mode that breaks the SPA flow. Use the JsonRedirect finisher instead — it puts the redirect target into api.actionAfterSuccess and lets the frontend decide what to do.

Since 5.0 the finisher is registered out of the box (form set friendsoftypo3/headless-form, EXT:headless/Configuration/Form/Headless/config.yaml) — use it directly in your form definition:

finishers:
  -
    identifier: JsonRedirect
    options:
      pageUid: '2'
      message: 'Thanks! You will be redirected shortly.'
Copied!

On success it emits { redirectUrl, statusCode, message } (message defaults to null, statusCode to 303 and is also an option). The redirectUrl is made relative only when its host already equals the site's frontendBase host; otherwise the absolute TYPO3-host URL is returned unchanged — it is not rewritten to frontendBase. It does not redirect by itself. Further options: additionalParameters (appended to the target URL) and sameSiteOnly — with it, a pageUid outside the current site (or an unresolvable one) falls back to the finisher's default target (pageUid: 1) instead of being used.

Submitting the form 

POST the form to the content element's link value — headless builds it with tx_form_formframework[action]=perform and tx_form_formframework[controller]=FormFrontend already appended. Nothing inside the form definition itself is a valid submit target.

Every field is submitted under its exact name, tx_form_formframework[<formId>][<identifier>]. Besides the visible fields, the JSON elements contain Hidden elements that must be posted back verbatim:

  • __state — the HMAC-protected form state; round-trip it unchanged.
  • __currentPage — the page index being submitted.
  • __trustedProperties — the extbase property-mapping token, generated for the exact set of listed field names: omitting any field (the honeypot included) fails property mapping.
  • __session — present only once the form is performing (after the first POST); echo it back on subsequent steps.

Honeypot 

When renderingOptions.honeypot.enable is true, an extra field with a session-random identifier appears in elements and its name is baked into __trustedProperties. Render it hidden from humans and submit it empty — filling or omitting it fails the submission. When using a custom honeypot element, its type must match renderingOptions.honeypot.formElementToUse (default Honeypot) for headless to expose it correctly in the JSON definition.

Customising the JSON output (decorators) 

EXT:headless provides three building blocks:

  • FriendsOfTYPO3HeadlessFormDecoratorFormDefinitionDecorator — default implementation.
  • FriendsOfTYPO3HeadlessFormDecoratorAbstractFormDefinitionDecorator — base class with hooks to override per-element or whole-form output.
  • FriendsOfTYPO3HeadlessFormDecoratorDefinitionDecoratorInterface — the contract a custom decorator implements.

Default output (FormDefinitionDecorator) — the decorator returns the bare definition; in the page response it sits under the content element's content.form key:

{
  "id": "ContactForm-1",
  "api": {
    "status": null,
    "errors": null,
    "actionAfterSuccess": null,
    "page": { "current": 0, "nextPage": null, "pages": 1 }
  },
  "i18n": { "submitButtonLabel": "Submit" },
  "elements": []
}
Copied!

elements is abbreviated here — in a real response it lists the current page's fields plus the hidden round-trip fields described in Submitting the form above.

Subclass AbstractFormDefinitionDecorator if you only need to tweak one element type or one form root field; implement DefinitionDecoratorInterface directly if you want full control.

Attach via renderingOptions.formDecorator as shown above.

EXT:felogin 

EXT:felogin works out of the box with headless. The headless XClass on LoginController swaps the HTML view for a JSON response that includes the form definition, the login status and any redirect target.

Setup 

Standard felogin setup — install (composer require typo3/cms-felogin), drop a login plugin onto a page, configure storage pages in plugin settings.

You can test the flow without a frontend with curl. TYPO3 v14 only evaluates credentials that are accompanied by a valid, nonce-signed __RequestToken, so a login is always two requests: fetch the form first, then post it back together with its cookies.

# 1) Fetch the login form; store the typo3nonce_* cookie
curl -s -c cookies.txt -H 'Accept: application/json' \
  https://api.example.com/login-page

# 2) Post credentials plus EVERY hidden field returned in
#    form.elements, sending the cookies back — the __RequestToken
#    is only valid together with its typo3nonce_* cookie
curl -i -b cookies.txt -c cookies.txt -X POST \
  -H 'Accept: application/json' \
  https://api.example.com/login-page \
  --data-urlencode 'user=joe' \
  --data-urlencode 'pass=secret' \
  --data-urlencode 'logintype=login' \
  --data-urlencode 'pid=<value from form.elements>' \
  --data-urlencode '__RequestToken=<value from form.elements>'
  # …plus all remaining hidden fields from form.elements, verbatim
Copied!

A successful login responds with a set-cookie header carrying the session cookie (fe_typo_user). On failure the JSON status flips to failure and message carries the rendered header/text for the failure state.

JSON response shape 

The plugin output sits under the content element's content.data key:

{
  "content": {
    "colPos0": [{
      "type": "felogin_login",
      "content": {
        "data": {
          "form": {
            "title": "Login",
            "action": "https://api.example.com/login-page",
            "method": "POST",
            "elements": []
          },
          "message": { "header": "…", "message": "…" },
          "status": "success",
          "recovery": "https://api.example.com/login-page?…",
          "flashMessages": []
        }
      }
    }]
  }
}
Copied!

recovery always carries the password-recovery URI (never null).

form.elements contains every input the frontend must submit. Echo each hidden field back verbatim, under its exact name:

  • logintype, pid, redirect_url, noredirect, redirectReferrer, referer and — when permalogin is offered — permalogin: plain felogin fields, unprefixed.
  • tx_felogin_login[__referrer][@extension], [@controller], [@action], [arguments], [@request]: extbase referrer fields, HMAC-protected.
  • tx_felogin_login[__trustedProperties]: the extbase property mapping token. It is emitted with the plugin prefix and must be posted back under that prefixed name.
  • __RequestToken: a TYPO3 RequestToken with scope core/user-auth/fe, signed against the typo3nonce_* cookie set on the GET request. This is the one field that stays unprefixed.

Without a complete, unmodified set of these fields the login is rejected before credentials are even checked.

When a redirect target applies, the plugin payload is replaced entirely by { "redirectUrl": "…", "statusCode": 303, "status": "…" } — the frontend performs the redirect itself.

Cookies & cross-domain 

The session cookie is scoped to the API domain by default. If your frontend lives on a different host, set a shared $GLOBALS['TYPO3_CONF_VARS']['FE']['cookieDomain'], or enable the headless.cookieDomainPerSite feature flag to derive it per site — see Multi-Site & URL Configuration.

Detect login in your own code 

use TYPO3\CMS\Core\Context\Context;

$context = GeneralUtility::makeInstance(Context::class);
$isLoggedIn = $context->getPropertyFromAspect('frontend.user', 'isLoggedIn');
Copied!

Or for the user's data:

$userId = (int)$context->getPropertyFromAspect('frontend.user', 'id');
$groups = $context->getPropertyFromAspect('frontend.user', 'groupIds');
Copied!

Listening for successful login 

Headless ships LoginConfirmedEventListener that decorates the JSON view with status = success. To run your own logic at the same point, listen to TYPO3 core's TYPO3CMSFrontendLoginEventLoginConfirmedEvent — see Events you can listen to for listener registration.

EXT:redirects 

Install EXT:redirects (composer require typo3/cms-redirects) and the standard redirect manager works as usual. EXT:headless then automatically swaps the relevant frontend middlewares so a matched redirect surfaces to a headless frontend as JSON rather than a 30x response — no feature flag required.

The replaced middlewares are:

  • typo3/cms-frontend/base-redirect-resolverFriendsOfTYPO3HeadlessMiddlewareSiteBaseRedirectResolver
  • typo3/cms-frontend/shortcut-and-mountpoint-redirectFriendsOfTYPO3HeadlessMiddlewareShortcutAndMountPointRedirect

Matched redirects from the redirect manager are turned into JSON by the headless/RedirectWasHit event listener (see below) — the core redirecthandler middleware stays in place.

Requests for the headless page-content type (?type=834) bypass shortcut and mount-point redirect handling entirely and are resolved downstream instead.

JSON response shape 

A matched redirect produces:

{
  "redirectUrl": "https://example.com/new-target",
  "statusCode": 301
}
Copied!

redirectUrl is run through UrlUtility::prepareRelativeUrlIfPossible(), so internal targets come back as relative paths (/new-target) when they land on the same frontend host.

The HTTP response itself is always 200statusCode tells the frontend which redirect to perform, and the frontend must apply the usual method semantics itself (301/302 may switch to GET, 307/308 preserve the request method). Its value depends on the source:

  • redirect-manager records: the record's configured target status code;
  • shortcut/mount-point and site-base redirects: the HTTP code the core middleware would have sent (typically 307).

Customising the redirect 

The JSON envelope is built by FriendsOfTYPO3HeadlessEventListenerHeadlessRedirectResponseListener, which listens to the core TYPO3CMSRedirectsEventRedirectWasHitEvent (identifier headless/RedirectWasHit). Register your own listener for the same event after it and replace the response:

final readonly class TagRedirectsForAnalytics
{
    public function __invoke(RedirectWasHitEvent $event): void
    {
        $response = $event->getResponse();
        if (!$response instanceof JsonResponse) {
            return;
        }

        $payload = json_decode((string)$response->getBody(), true);
        $payload['redirectUrl'] .= '?utm_source=redirect';
        $event->setResponse(new JsonResponse($payload));
    }
}
Copied!
# Configuration/Services.yaml
services:
  Vendor\MyExt\EventListener\TagRedirectsForAnalytics:
    tags:
      - name: event.listener
        identifier: 'myext/redirect/analytics'
        after: 'headless/RedirectWasHit'
Copied!

Page targets carrying Extbase plugin parameters in the typolink additionalParams segment are rebuilt through the site router by FriendsOfTYPO3HeadlessRedirectsTargetUrlResolver (the core RedirectService drops that segment unless "keep query parameters" is enabled). Override or extend that service via Services.yaml when you need different target URL resolution.

Short URLs & QR codes (5.x) 

The "Short URLs" and "QR Codes" backend modules of EXT:redirects show source URLs on the TYPO3 host — useless when the public site lives on the frontend domain. With headless, both modules (and the QR-code/short-URL fields in redirect records) resolve source URLs against the site's frontendBase instead, so copied links and scanned codes land on the public frontend. No configuration needed beyond frontendBase; resolution logic can be replaced by overriding FriendsOfTYPO3HeadlessRedirectsSourceUrlResolver via Services.yaml.

Reference: TypoScript helpers 

lib.* keys provided by the headless TypoScript. Override any of them from your site setup to customise the JSON response.

Key Purpose
lib.headlessPage Bare PAGE skeleton (JSON headers, cache config, an empty 10 = JSON). The response page object is created from it via parse-time copy (page < lib.headlessPage) and the fields live on page.10.fields — override those, not the lib.
lib.content CONTENT_JSON over tt_content grouped by colPos. Referenced by page.10.fields.content.
lib.contentElement Base JSON shape for every content element.
`lib.contentElementWithHeade lib.contentElement plus the standard header block. Use this as the base for custom CEs that should keep the title/subtitle/header link block.
lib.galleryContentElement lib.contentElementWithHeader plus the shared image-gallery block (image, textpic, textmedia).
lib.meta Legacy SEO meta object (legacy set only) — title, description, keywords, robots, ogImage. The default set uses the seo object instead.
lib.appearance Per-content-element appearance block: layout (default/layout-1..3), frameClass, spaceBefore, spaceAfter.
lib.pageAppearance Page-level appearance block: layout (layout-*) and backendLayout.
lib.breadcrumbs MenuProcessor with special = rootline.
lib.categories sys_category lookup for the current page (legacy set only).
lib.backendEditor Backend "edit this" link object — attached to the initialData page type (834) for logged-in BE users only, not to the page response.
lib.renderChildren Recursive content element renderer — used by CEs that contain other CEs (containers, gridelements, etc.).

Common overrides 

Add a top-level field to every page response (the page object is a parse-time copy of lib.headlessPage, so override page, not the lib):

page.10.fields {
  buildId = TEXT
  buildId.value = {$myVendor.buildId}
}
Copied!

Replace the meta object on a specific route — see Overriding the meta object (legacy set).

Make every content element carry a custom field:

lib.contentElement.fields.myField = TEXT
lib.contentElement.fields.myField.field = my_field
Copied!

Read the shipped TypoScript to see the defaults — every override above just adds to or replaces values inside the same structure.

Files 

The default TypoScript lives in EXT:headless/Configuration/TypoScript/ and is loaded by the site sets under EXT:headless/Configuration/Sets/. Subdirectories:

  • Page/lib.headlessPage and its children.
  • ContentElement/lib.contentElement + per-type CEs (textmedia, bullets, menus, uploads, felogin, …).
  • Helpers/lib.renderChildren, lib.parseFunc_links. (lib.parseFunc and lib.parseFunc_RTE are provided globally by EXT:frontend since TYPO3 v13.2.)
  • Configuration/ — language/backend editor wiring.
  • Legacy/ — 4.x-only additions: lib.meta, lib.categories (page and content element variants) and the full legacy page object. Loaded by the legacy set, the static template and mixed mode — never by the default set.
  • PageResponse.typoscript — the trimmed default page object, loaded only by the default set.

The default set 

friendsoftypo3/headless (label "TYPO3 Headless", EXT:headless/Configuration/Sets/Headless/) ships a trimmed response by default. The legacy set friendsoftypo3/headless-legacy (label "TYPO3 Headless Legacy (4.x)") declares the default set as a dependency and only loads the Legacy/ delta on top: the full 4.x page object, lib.meta and the categories fields. It is the quick upgrade path for existing installs.

Sites not (yet) on sets can select the equivalent sys_template statics instead: "Headless" (EXT:headless/Configuration/TypoScript/Headless, the trimmed default response), "Headless Legacy (4.x)" (EXT:headless/Configuration/TypoScript, the full 4.x response) and "Headless - Mixed mode JSON response" (EXT:headless/Configuration/TypoScript/Mixed). 4.x records keep working unchanged — the 4.x "Headless" static stored the path that is now registered as the legacy item, and the mixed static kept its path. Site packages that @import EXT:headless/Configuration/TypoScript/setup.typoscript directly also still get the full 4.x response. Note that a root sys_template record with "Clear" flags wipes all set-provided TypoScript — delete the record when switching a site to sets.

The default set imports the same lib.* helpers as the legacy setup — everything in the table above still applies — with two exceptions: lib.meta and lib.categories are not loaded, and lib.contentElement does not define a categories field at all (the legacy delta re-adds it, which restores the field on every shipped content element at once — all CEs reference lib.contentElement at render time via =<).

Default page response 

{
  "id": 1,
  "type": "Standard",
  "slug": "/",
  "media": [],
  "seo": { "title": "…", "meta": [], "htmlAttrs": {}, "bodyAttrs": {} },
  "breadcrumbs": [],
  "appearance": { "layout": "layout-0", "backendLayout": "default" },
  "content": {},
  "i18n": []
}
Copied!

The seo object is populated at runtime: the TypoScript only ships a placeholder with seo.fields.title, which the AfterCacheableContentIsGenerated listener detects before handing the response to the MetaHandler. Do not remove the placeholder — without a seo.title key in the rendered JSON, no seo data is generated at all. page.meta. (e.g. the generator entry) is likewise still read by the MetaHandler to build seo.meta.

The additional page types of the legacy setup are available unchanged: initialData (typeNum = 834) and headless_domains (typeNum = 835).

Differences to the legacy set 

Removed key Replacement / how to get it back
page.10.fields.meta Deprecated duplicate of seo — use seo, which the MetaHandler fills from the same page properties (title, description, OpenGraph/Twitter images, …).
page.10.fields.categories Opt-in; see "Content element categories" in the Configuration chapter (covers page categories too).
`lib.contentElement.fields.categorie Opt-in; same Configuration chapter section.
plugin.tx_headless.staticTemplate Not set by this set. Only relevant for third-party extensions probing it to detect headless rendering.

Every removal saves work on uncached renders: meta cost two extra FilesProcessor runs per page, and the categories fields cost one sys_category join per page respectively per content element — while returning an empty string unless the storage pid was reconfigured.

Reference: Events 

PSR-14 events dispatched by EXT:headless. For registration syntax and a worked example see Events you can listen to.

EnrichFileDataEvent 

FriendsOfTYPO3HeadlessEventEnrichFileDataEvent

Fired by FileUtility::process() once a file's default properties have been collected, before crop variants and autogenerate derive extra fields. The canonical hook for adding custom file fields (signed URLs, focus point, alt-text overrides) to every file in the response.

Method Returns
getProperties(): array The current property bag (mutable via set).
setProperties(array): void Replace the property bag.
`getProcessed(): FileInterface The processed file (after FAL).
getOriginal(): FileInterface The original file reference.
getProcessingConfiguration() ProcessingConfiguration value object.

FileDataAfterCropVariantProcessingEvent 

FriendsOfTYPO3HeadlessEventFileDataAfterCropVariantProcessingEvent

Fired once per file, after all crop variants have been processed (and also when the file has none). Use it to post-process the complete file payload including its cropVariants. The event carries the base per-file ProcessingConfiguration that was passed into processCropVariants() — the per-variant configurations derived inside the loop never reach the event.

Method Returns
getProcessedFile(): array The whole processed-file payload.
setProcessedFile(array): void Replace it.
getOriginal(): FileInterface The original file reference.
getProcessingConfiguration() The per-file ProcessingConfiguration (not per-variant).

Core events you may also care about 

These come from TYPO3 core but are commonly used alongside headless:

  • TYPO3CMSRedirectsEventRedirectWasHitEvent — headless's HeadlessRedirectResponseListener (identifier headless/RedirectWasHit) builds the JSON redirect envelope from this event. Register your own listener after it to replace the response — see EXT:redirects.
  • TYPO3CMSFrontendEventAfterCacheableContentIsGeneratedEvent — the headless AfterCacheableContentIsGeneratedListener listens to this to bake SEO meta tags into the JSON response. Listen too if you need to post-process the full encoded JSON before it goes into the page cache.
  • TYPO3CMSFrontendEventModifyHrefLangTagsEvent — headless's HeadlessHreflangGeneratorListener rewrites hreflang URLs through UrlUtility.
  • TYPO3CMSFrontendEventAfterLinkIsGeneratedEvent — headless's AfterLinkIsGeneratedListener rewrites typolink URLs through UrlUtility.
  • TYPO3CMSCoreRoutingEventAfterPageUriGeneratedEvent — headless's AfterPageUriGeneratedListener rewrites the host on URIs produced by PageRouter::generateUri() — BE "view" links, workspace split-screen previews and similar admin tooling land on the configured frontendBase. It acts only on backend requests and only when headless mode is enabled for the resolved site.
  • TYPO3CMSCoreResourceEventGeneratePublicUrlForResourceEvent — headless's ProxyResourcePublicUrlListener (identifier headless/ProxyResourcePublicUrl) rewrites public URLs of local, publicly capable storages through the configured storage proxy. Registered only when the headless.storageProxy feature flag is enabled, and acts only on frontend requests with headless mode on.
  • TYPO3CMSFrontendLoginEventLoginConfirmedEvent — headless assigns the success status to the JSON login response.

See Events you can listen to for listener registration syntax.

Reference: Feature Flags 

Quick lookup. For per-flag descriptions and usage see Configuration.

Active in 5.x 

Flag Effect
headless.storageProxy Route processed file URLs through frontendFileApi.
headless.elementBodyResponse On POST/PUT/DELETE, return just the element matching responseElementId from the body.
`headless.overrideFluidTemplates Swap ViewFactoryInterface for HeadlessViewFactory. Required to render raw-PHP templates per view.
headless.cookieDomainPerSite Per-site cookieDomain injection middleware.
headless.assetsCacheBusting Append ?<mtime> to processed-file URLs.
headless.prettyPrint JSON_PRETTY_PRINT on every encoder output.

Availability by version 

Flag

2.x

3.x

4.x

5.x

FrontendBaseUrlInPagePreview

available

removed

removed

removed

headless.frontendUrls

>= 2.5

available

removed

removed

headless.storageProxy

>= 2.4

available

available

available

headless.redirectMiddlewares

>= 2.5

available

available

removed (auto-on when EXT:redirects is installed)

headless.nextMajor

>= 2.2

currently not used

currently not used

removed

headless.elementBodyResponse

>= 2.6

available

available

available

headless.simplifiedLinkTarget

>= 2.6

removed

not available

not available

headless.jsonViewModule

not available

>= 3.0

>= 3.0

removed (module discontinued)

headless.workspaces

not available

>= 3.1

removed (auto-on with EXT:workspaces)

removed (works without a flag)

headless.pageTitleProviders

not available

not available

>= 4.2.3 <= 4.4

removed

headless.overrideFluidTemplates

not available

not available

available

available (reworked, see UPGRADE.md)

headless.cookieDomainPerSite

not available

not available

available

available

headless.assetsCacheBusting

not available

not available

available

available

headless.prettyPrint

not available

not available

available

available

Reference: Interfaces 

Public interfaces in the `FriendsOfTYPO3Headless` namespace. Inject these rather than the concrete classes — it keeps your code testable and DI-friendly.

Interface What it gives you
UtilityHeadlessModeInterface Detect / switch headless mode for a request. Methods: isEnabled(), isEnabledFor($request), withRequest($request), overrideBackendRequestBySite($site, $language).
UtilityHeadlessFrontendUrlInterface URL rewriting from backend to frontend. Methods: withSite(), withRequest(), getFrontendUrl(), getFrontendUrlWithSite(), getFrontendUrlForPage(), getProxyUrl(), getStorageProxyUrl(), resolveKey(), prepareRelativeUrlIfPossible(). (withLanguage() exists only on the concrete UrlUtility.)
UtilityFileUtilityInterface File / image rendering. Methods: setRequest(), processFile(), process(), processImageFile(), processCropVariants(), getAbsoluteUrl(), getErrors(). process() returns the same shape that ships in content.*.media by default.
JsonJsonEncoderInterface encode($data, $options = 0). Wraps json_encode with HTML-safe flags and the headless.prettyPrint feature flag. Does not throw on invalid data: encoding errors are caught, logged as critical, and "[]" is returned.
JsonJsonDecoderInterface decode(array) recursively unwraps JSON strings nested inside arrays. isJson($mixed) detects JSON by fully decoding the value after cheap pre-checks.
SeoMetaHandlerInterface process($request, $content) — runs the SEO meta-tag pipeline (page title, meta registry, hreflang) and merges it under content.seo.
DataProcessingRootSiteProcessingSiteProviderInterface Strategy for RootSitesProcessor to discover sites.
DataProcessingRootSiteProcessingSiteSchemaInterface Strategy for shaping the JSON each site produces.
DataProcessingRootSiteProcessingSiteSortingInterface Strategy for sorting sites in the output.
FormCustomOptionsInterface Dynamic options for a form select/radio/checkbox. See EXT:form.
FormDecoratorDefinitionDecoratorInterface Custom JSON shape for an EXT:form definition.

DI wiring 

FileUtilityInterface, HeadlessFrontendUrlInterface, JsonEncoderInterface, JsonDecoderInterface and MetaHandlerInterface are aliased in the extension's Services.php; HeadlessModeInterface via #[AsAlias] on HeadlessMode. Plain constructor injection works for those:

final readonly class MyService
{
    public function __construct(
        private HeadlessModeInterface $headlessMode,
        private HeadlessFrontendUrlInterface $urlUtility,
        private JsonEncoderInterface $jsonEncoder,
    ) {}
}
Copied!

The remaining interfaces (RootSiteProcessing*, FormCustomOptionsInterface, DefinitionDecoratorInterface) are contracts you implement and hand to the extension via TypoScript or form YAML — they carry no container alias.

UrlUtility and HeadlessMode are shared services; state safety comes from their wither methods (withRequest() / withSite() return clones), not from share: false.

FAQ 

How to use EXT:felogin? 

Using EXT:felogin with the headless extension follows the standard setup as detailed in the felogin documentation; the headless-specific JSON output is described in EXT:felogin.

To test the login without a frontend, first GET the login page to obtain the nonce-signed __RequestToken hidden field and its typo3nonce_* cookie, then POST the credentials together with both — if the response contains a set-cookie header for the session, the login was successful. See EXT:felogin for the full flow.

Does EXT:headless work with other extensions? 

Yes, the output of virtually any extension can be rendered into the JSON response. For detailed information, refer to the integration of external plugins section of this documentation. Additionally, you can review the code of headless_news as an example of how this integration works.

How to handle redirects in a headless setup? 

The frontend application performs the actual redirect: a matched redirect from EXT:redirects is returned as JSON ({ "redirectUrl": "...", "statusCode": 301 }) instead of an HTTP 30x response.

On headless 5.x (TYPO3 v14) this works automatically as soon as EXT:redirects is installed — no feature flag. See EXT:redirects for the response shape and customisation.

On headless 4.x and below, enable it with the headless.redirectMiddlewares feature flag:

$GLOBALS['TYPO3_CONF_VARS']['SYS']['features']['headless.redirectMiddlewares'] = true;
Copied!

Can I use custom fields or content elements with EXT:headless? 

Yes, EXT:headless supports the customization of JSON responses using TypoScript. You can define custom fields or content elements and extend the JSON output to include these customizations.

For example, to add a custom field, you can modify the TypoScript setup like this:

lib.customField = TEXT
lib.customField.value = My Custom Field
Copied!

This value can then be included in the JSON response as needed.

How to configure language and translation settings? 

EXT:headless fully supports TYPO3's language and translation configurations, including fallback settings. To configure languages, follow these steps:

  1. Define your languages in the site configuration YAML file.
  2. Ensure that your content elements and page properties are translated according to TYPO3's multilingual guidelines.

For example, in your site configuration:

languages:
  - languageId: 0
    title: English
    enabled: true
    base: /en/
    typo3Language: default
    locale: en_US.UTF-8
    navigationTitle: English
    hreflang: en
    flag: global
  - languageId: 1
    title: German
    enabled: true
    base: /de/
    typo3Language: de
    locale: de_DE.UTF-8
    navigationTitle: Deutsch
    hreflang: de
    flag: de
Copied!

The JSON API will respect these settings and provide the appropriate language versions of the content.

How to enable clean output for plugins in EXT:headless? 

To enable clean output middleware for plugins, which is available for POST/PUT/DELETE method requests, follow these steps:

  1. Set the headless.elementBodyResponse feature flag in config/system/settings.php:

    $GLOBALS['TYPO3_CONF_VARS']['SYS']['features']['headless.elementBodyResponse'] = true;
    Copied!
  2. Enable the headless mode in your site configuration's YAML file:

    headless: 1
    Copied!
  3. Send the responseElementId field with the ID of the plugin in the body of the plugin data during requests.

On a mixed-mode site (headless: 2), the request must additionally carry the exact Accept: application/json header, or the middleware does not act.

For example, a POST request might look like this:

POST https://example.tld/path-to-form-plugin
Content-Type: application/x-www-form-urlencoded

responseElementId=#ELEMENT_ID#&tx_form_formframework[email]=email&tx_form_formframework[name]=test...
Copied!

To handle nested elements, use the responseElementRecursive flag:

POST https://example.tld/path-to-form-plugin
Content-Type: application/x-www-form-urlencoded

responseElementId=#ELEMENT_ID#&responseElementRecursive=1&tx_form_formframework[email]=email&tx_form_formframework[name]=test...
Copied!

Sitemap