What Actually Belongs in the ui.apps Module (and What Doesn't)
A field guide to what an AEM project's ui.apps Maven module should ship — components, clientlibs, i18n dictionaries — and why authored content never belongs there, with the FileVault filter.xml rules that decide what gets overwritten or deleted on deploy.
ui.apps is the Maven module in an AEM project that builds a FileVault
content package installing into /apps. That much every AEM developer
knows. What causes real incidents is less obvious: exactly which nodes are
allowed to live there, and what the package’s filter.xml does to
anything else it finds under those paths at deploy time. Get the scope
wrong and a routine ui.apps deploy can silently delete content that has
nothing to do with your change.
This article assumes you already know the standard multimodule layout
(core, ui.apps, ui.content, ui.frontend…) — it focuses only on
what ui.apps should contain and how its FileVault filter controls what
happens on install.
What ui.apps actually ships
ui.apps is a packageType=application content package. In AEM as a
Cloud Service, an application package is only allowed to touch /apps —
never /content, /conf, or any other runtime-writable area. What
belongs under /apps is, by definition, code and configuration authored
by developers and shipped through CI/CD, not content authored by editors
in the AEM UI:
- Components — the
cq:Componentnode structure:.content.xml(dialog,sling:resourceType,sling:resourceSuperType), the HTL script, and any Sling Model registration that lives alongside it under/apps/mysite/components/.... - Clientlib folder definitions — the
cq:ClientLibraryFoldernode itself:categories,embed,dependencies,jsProcessor/cssProcessor, and thejs.txt/css.txtmanifests under/apps/mysite/clientlibs/.... This is distinct from the actual minified JS/CSS output: that’s typically built by the separateui.frontendmodule and copied into this same folder structure as part of the Maven build, but the folder’s definition — its category name, its dependency graph — isui.appscontent, notui.frontendcontent. - i18n dictionaries under
/apps—sling:MessageEntry/mix:Languagenodes for developer-owned UI strings (dialog field labels, component copy that isn’t editable content) under/apps/mysite/i18n/....
One thing that surprises teams coming from /apps-centric thinking:
editable template definitions and their policies do not ship from
ui.apps, even though they feel like “code.” A cq:Template and its
cq:Policy nodes live under /conf/mysite/settings/wcm/..., and /conf
is a mutable, author-editable path — policies in particular are routinely
edited by authors through the “Edit Template” UI in production. Since an
application package can only touch /apps, templates and policies have
to ship from a content-type package (usually ui.content, or a
dedicated ui.config/structure module), typically with mode="merge" so
a redeploy doesn’t stomp on policy tweaks an author made after go-live.
What genuinely belongs to ui.apps from the template world is the
structure component’s code — the HTL/Java backing the layout container —
not the template or policy node itself.
Why authored content must never live in ui.apps
Pages under /content, DAM assets under /content/dam, and tags used to
classify that content are ui.content’s job, never ui.apps’s — and this
isn’t just a style preference, it’s enforced by AEM as a Cloud Service:
a single content package cannot deploy to both /apps and a
runtime-writable area like /content at all. But even short of that hard
rule, mixing the two causes real damage:
- Cloud Manager promotions get harder to reason about. Full-stack
builds bundle
ui.appsalongside everything else; if a reviewer can’t assume “this package is pure code,” every promotion requires re-checking whether some content snuck in. - Content gets silently overwritten or deleted on the next deploy.
ui.appsis treated as fully replaceable — every pipeline run reinstalls it from scratch. FileVault’s default import mode isreplace: anything covered by the package’s filter but not present in the package’s archive is removed from the repository on import. A page or asset that ended up under aui.appsfilter root has a very short life expectancy. - The wrong node in the wrong package clobbers author work. This
usually happens by accident: someone runs
vlt checkoutor exports a package against a broad root, picks up a node an author created under a path the package now claims, and the nextui.appsdeploy deletes it without anyone touching Package Manager directly.
filter.xml: what a filter root actually controls
Every content package — ui.apps included — declares its scope in
META-INF/vault/filter.xml using <filter root="..."> elements. A filter
root is not a hint about “where this package mostly puts stuff”; per the
Apache Jackrabbit FileVault documentation,
it defines the subtree the package owns for the purposes of import:
- The default import mode is
replace: existing content under a covered root is replaced by whatever the package brings — overwritten or deleted as needed to match the package exactly. - Critically: “Nodes/Properties being covered by some filter rules but
not contained in the to-be-imported content are removed from the
repository.” If your filter root is
/apps/mysitebut your package’sjcr_rootdoesn’t actually contain a node that already exists on the target under that path, FileVault deletes it on install — the filter root, not the package contents, decides what’s in scope for deletion. - Content outside any declared filter root is left untouched, whatever it is.
<include>/<exclude>elements inside a<filter>narrow that root further. They’re evaluated in order against the full JCR path, and the last matching rule wins — so ordering them wrong silently changes what a filter actually covers.
That single fact — uncovered-but-declared paths get deleted, not ignored — is the mechanism behind almost every “a deploy wiped out content that had nothing to do with my change” incident.
A realistic ui.apps filter.xml
<?xml version="1.0" encoding="UTF-8"?>
<workspaceFilter version="1.0">
<filter root="/apps/mysite/components"/>
<filter root="/apps/mysite/clientlibs"/>
<filter root="/apps/mysite/i18n"/>
</workspaceFilter>
Three narrow, explicit roots — each one matching exactly the subtree this
module is responsible for. Nothing here claims /apps wholesale, and
nothing here reaches into /conf or /content.
The too-broad mistake
<filter root="/apps"/>
This looks like a harmless shortcut — “we own /apps/mysite, and
/apps/mysite is under /apps, so why not.” But the filter root is
/apps itself. On install, FileVault now considers the entire /apps
tree — including AEM’s own Core Components under /apps/core, another
team’s package under /apps/othersite, anything — to be covered by this
package. Since your package’s jcr_root only actually contains
apps/mysite/..., everything else under /apps is “covered but not
contained,” and gets deleted on import. This is precisely how a routine
ui.apps deploy takes down Core Components or a sibling application on a
shared AEM instance.
The too-narrow mistake
<filter root="/apps/mysite/components"/>
<!-- someone adds /apps/mysite/templates/structure locally and forgets
to add a filter root for it -->
The content-package-maven-plugin builds the package strictly from what
filter.xml covers. A folder added under jcr_root that isn’t under any
declared filter root is simply excluded from the built package — no
warning, no build failure. It’s committed to git, it exists on disk, and
it never reaches the target instance. This is the quieter failure mode:
nothing breaks visibly, a feature just never shows up, and the fix is
usually “someone forgot to add a <filter root> line.”
Where this comes up
- Cloud Manager deployment failures from overlapping filter roots.
Adobe’s own guidance is explicit that the workspace filter of a
container package should never overlap with the workspace filter of an
application package, and the same applies between two application
packages. If
ui.appsand a repository-structure or vendor package both declare a root that covers the same path, the pipeline can fail validation, or worse, install successfully but leave the two packages fighting over the same nodes on every subsequent deploy. When a Cloud Manager pipeline fails at the deploy step with package-related errors, diffing thefilter.xmlof every content package in the submission against each other is one of the first things worth checking. - Content disappearing right after a ui.apps deploy. If something
under
/appsvanishes the moment aui.appspackage installs, the filter root is almost always the answer: either the root is broader than intended and swept up something it shouldn’t own, or a node that used to be covered by an older filter got orphaned when the filter was narrowed without migrating the content first. Comparing thefilter.xmlbetween the previous and current package version — not just the diff ofjcr_root— is the fastest way to find it.