Architecture best practices
Opinionated structural guidance for ApostropheCMS projects: project and module organization, front and back end architecture and common pitfalls.
-
- Intermediate
- 12 min read
- Last Updated August 4, 2026
How is an ApostropheCMS project structured?
ApostropheCMS projects are composed of modules. Each module is made up of related functionality that achieves a shared purpose. For simplicity, we’ll start by looking at a typical single-site, “vanilla” project to understand how modules are laid out.
Projects have a top-level app.js file, which acts as the entry point and activates modules, and a modules/ subdirectory that contains modules and configuration specific to the project. Modules that are useful across projects can also be installed via npm and then activated via app.js in the usual way.
ApostropheCMS will automatically load the index.js file for each module, as long as the module is activated in app.js.
Typical ApostropheCMS project structure
Organizing your content types: how to model your content with Apostrophe modules
Most modules are not written from scratch. Instead, most modules extend a specific “base class” module and inherit much of their functionality without the need for new code. Understanding this is key to organizing your content types to match your project’s needs.
Most modules extend one of these:
@apostrophecms/page-type, which provides a distinct page template, with its own set of editable fields. Pages have a fixed URL within the site and are part of the page tree. “Home,” “About,” and “Contact Us” are common examples of pages. For more information, see pages and page types.@apostrophecms/piece-type, which provides a distinct document type such as an article, event, product or category. If there’s a need for a paginated or filtered list, it should be a piece type. If it might be referenced in multiple places on the same site, it should be a piece type. For more information, see pieces.@apostrophecms/widget-type, which provides a single editable component, or “widget.” Most pages and pieces are primarily composed of sequences of widgets. A sequence of widgets is known as an “area,” and most pages and pieces contain at least one area. Widgets can contain sub-areas of their own, which allows for sophisticated layout effects. For more information, see custom widgets. You may also be interested in the core widgets that are provided standard.@apostrophecms/piece-page-type, a special page type that provides an “index page” (a listing) of pieces. Think of an entire blog, versus an individual blog post. “Piece pages” have two templates,views/index.htmlandviews/show.html, to render the index page versus a standalone blog post. In the earlier example,article-pageis a piece page type. For more information, see piece index and show pages.
Relationships versus arrays: when to use each
Apostrophe has rich support for relationships between content types. Any piece, page or widget can have relationship fields, allowing the editor to connect that content to other pages or pieces.
However, Apostrophe also supports array fields. And arrays have fields of their own.
Consider a business piece type. Each business might have more than one location.
If locations are purely a part of an individual business then the choice is clear. Use an array field, like this:
On the other hand, if a location will ever be shared by more than one business, or you might want to link to individual locations on the site, then location should be a piece type in its own right (a module extending @apostrophecms/piece-type), and business should have an _addresses relationship field relating it to locations:
Either way, the locations will be pre-loaded and ready in your templates. For more information, see relationships and arrays.
Best practices for relationship fields
In the above example, locations probably don’t have a lot of fields. But in other cases, such as relationships between “categories” and “projects,” the performance impact of loading all of the related documents can be a concern.
To avoid that, and other common problems, follow these strategies:
Choose the direction of the relationship wisely. If you have a
categorypiece type with hundreds of relevantarticlepieces, make surearticlehas a relationship withcategoryand not the other way around. Otherwise, hundreds of articles are loaded each time a category is loaded. Of course, you will still want to let users browse by category, so take advantage of thepiecesFiltersfeature of piece pages. This feature is optimized to support server-side pagination and avoid exhaustively loading everything.Always specify a projection. By default, a relationship will load every field of the related documents. For simple pieces this is OK, but as you begin to add rich content like editable areas full of widgets to your pieces, it becomes unacceptable overhead. For more information, see filtering document properties.
If you need to access the related documents from the “other side,” use
relationshipReverse. Returning to our earlier example, if you need to start fromlocationpieces and access their_businesses, don’t create a redundant relationship. Instead, take advantage of therelationshipReversefield type. This field can’t be edited directly; it populates automatically based on the correspondingrelationship. Just remember that using a projection is even more important with reverse relationships.
Common elements of modules
The more common elements of Apostrophe modules include:
Fields. Page, piece and widget modules get their “model layer” and editing UI for free, just by specifying a set of fields. Many field types are provided, including nested arrays with their own set of fields. The “area” field type allows on-page editing.
API routes. Pages and pieces automatically have REST API routes, and these can be selectively enabled for the general public. Modules can also easily define additional API routes, using simple
asyncfunctions and return values, avoiding the bookkeeping of Express.Templates. In pure ApostropheCMS projects, Nunjucks or JSX templates can reside right in the appropriate module. For instance,
views/widget.jsximplements the front end markup for a widget type module via a JSX template, very similar to a pure functional React component. In hybrid Astro / Apostrophe projects, a one-to-one correspondence is maintained between Astro components and the Apostrophe templates that would otherwise be needed.Async components. Async components provide a well-structured way to package both back end logic and front end templates for a frequently reused page fragments.
Methods. Modules can implement their own methods and inherit, extend and override the methods they inherit. Methods are an effective way to deliver code invoked by more than one API route, more than one async component, or even more than one module.
These are some of the most commonly used features. For a complete technical guide to what modules can do, see module configuration.
Modules can also be installed from npm
Most modules in an ApostropheCMS project are specific to the project and live in the modules/ subdirectory. However, external modules like @apostrophecms/redirect, @apostrophecms/blog and @apostrophecms/events can also be installed using npm install. When a module is activated in app.js, ApostropheCMS will search both npm and the local modules/ directory. If the same module name is found in both places, Apostrophe will treat the local version as an “improvement,” automatically merging its configuration with the npm module at startup.
To get a sense of the larger ApostropheCMS ecosystem of npm modules, check out the official extensions page.
Front end architecture and best practices
Front end code can be divided into three main parts: server-side, noninteractive HTML, interactive JavaScript components, and styles.
Best practices for server-rendered HTML
For SEO, AEO, GEO and browser performance reasons, most of the HTML making up your pages should be rendered by a server. Depending on your needs, you may prefer to do it with simple JSX templates, Astro, or Nunjucks.
When to use our built-in JSX support
Most modern development teams are already familiar with React, and so they tend to strongly prefer JSX templates. And ApostropheCMS supports JSX directly. So for many projects, JSX is all you need. And choosing JSX tends to simplify projects because there is no need for separate frontend and backend sub-projects.
In short, our built-in JSX support is a great place to start and will feel familiar to most developers today.
However, see below for situations where Astro may be preferable.
When to use Astro
Apostrophe also supports Astro. Astro is a better choice in the following situations:
You want to use a frontend framework syntax other than JSX, such as Vue SFC, Angular or SvelteJS. Astro supports freely combining all of these in the same project. However, note that using our built-in JSX support on the server side is functionally identical to React SSR. So if your team prefers React, this by itself is not a reason to choose Astro.
You want to move seamlessly between server-side and browser-side rendering without rewriting code. Astro has rich support for easily pivoting a single React component between the front end and the back end.
Your team prefers Astro. If your development team already prefers Astro, they will perceive our Astro hybrid projects as an advantage, and you may find it easier to integrate existing Astro code, especially non-JSX code.
When to use Nunjucks
Finally, Apostrophe supports Nunjucks. Similar to the Liquid template syntax found in products like Shopify, Nunjucks is typically found in existing Apostrophe projects. Like our native JSX support, it is best used in projects with relatively light requirements for in-browser JavaScript.
Because JSX is more widely used and provides better debugging traces, we encourage and support gradual migration from Nunjucks to JSX, “from the leaves up” (templates first, shared layouts last).
Interactive JavaScript components with ApostropheCMS
While modern website development tends to favor as much static HTML and server-side rendering as possible, interactive JavaScript always has its place. While ApostropheCMS supports several patterns here, to simplify your decision we’ll recommend two: ui/src and Astro.
Interactive JavaScript with ui/src
ui/src is a built-in feature of ApostropheCMS. Every module can contain an entry point for modern ESM JavaScript:
Each module’s index.js file may import other files as it sees fit, including npm dependencies. This is a great pattern for vanilla JavaScript, which is often the efficient choice.
How to keep your JavaScript interactive in the editor with ui/src
A frequent challenge is adding interactive JavaScript to widgets. While you can use a load event handler on the window, this does not work when an editor first adds a widget to the page, because the page has already been loaded at that point.
Instead, the official pattern is to write a widget player and register it with Apostrophe. A widget player is a simple function, paired with a DOM selector. Apostrophe takes care of recognizing when a widget has arrived on the page — whether at page load time, or via the editor. All you have to do is write the function. This pattern provides excellent separation of concerns.
If you have custom code that adds HTML to the page at other times, for instance via an AJAX request, you can ensure widget players run by calling apos.util.runPlayers() at that time. This is safe because Apostrophe will not invoke a player for the same widget twice.
Interactive JavaScript with Astro
If you choose Astro for your project, interactive JavaScript becomes Astro’s responsibility. Here you are spoiled for choice. For instance, you can choose to create a React component on the browser side or the server side.
How to keep your JavaScript interactive in the editor with Astro
While Astro’s techniques for running JavaScript in the browser are tempting, for the best editor experience HTML5 web components are the best path forward:
Your Astro component can take care of emitting the markup, on the server side, minimizing work in the browser.
The web component, via a
connectedCallback()handler, can always recognize when the element has been newly added to the DOM and do the right thing… regardless of whether it was there at page load time, or added on the fly with the editor.Apostrophe will never remove the widget and then add the same DOM element back to the page. It will always be a new element, so this is a safe policy to follow.
To learn more, see our Creating Widgets in ApostropheCMS + Astro tutorial.
Architecture of Astro / Apostrophe Hybrid Projects
We’ve made a number of references to Astro above. Here’s how hybrid Astro / Apostrophe projects are actually laid out.
A hybrid Astro / Apostrophe project is typically structured as a single repository with two main subdirectories:
Hybrid projects contain no .html templates in backend/. Instead each is mapped to an Astro component. Astro components, in turn, can import React, Vue, SvelteJS, Angular and other frontend components or simply render the content on their own.
Astro lives in the frontend directory
The frontend directory contains a complete Astro subproject. Requests from the browser reach Astro first. Astro passes on the request to ApostropheCMS, which responds with the same data it would normally use to render its own templates. The Astro project, in turn, contains a one-to-one mapping from ApostropheCMS template names to Astro component names.
This “inversion of control” allows on-page editing with ApostropheCMS to coexist with the fast, modern, frontend framework agnostic development experience of Astro. Astro, for its part, allows developers to code their frontend with their frontend framework of choice: React, Angular, SvelteJS and Vue all supported, just for example.
ApostropheCMS lives in the backend directory
The backend directory contains the ApostropheCMS subproject. The structure here is just as described earlier, with an app.js starting point, and a modules/ subdirectory. However, in an Astro / Apostrophe hybrid project backend contains no .html or .jsx templates of its own. Instead rendering chores are handled by the Astro “external front end.”
Multitenant / multisite projects
While Apostrophe’s open source core is great for single-tenant projects, we have a special focus on support for multitenant projects. We deliver that through our Apostrophe Assembly license and the @apostrophecms/multisite module that implements it.
Multitenant Apostrophe projects serve more than one site from a single codebase. To do that, they require a slightly different structure:
For more information, see the documentation of our multisite module.
Multitenant Astro Projects
Astro and multitenant can be combined in a single project:
In multitenant projects, everything on the customer-facing individual sites is rendered by Astro. However, the special dashboard site that manages the others is directly rendered by ApostropheCMS, and Astro just proxies those requests. This saves developers the trouble of building separate Astro templates for the dashboard.
Mistakes to avoid
Here are some additional pitfalls to steer clear of:
Hardcoded configuration. Don’t hardcode values that differ between dev, staging and production environments. Use environment variables for this.
.envfiles are helpful here, usually best when listed in.gitignore. In deployed environments, use the mechanisms of your chosen ops platform to set the variables.Retrofitting localization of static text. User-edited content can easily be localized later by adding more locales. But static text in hardcoded templates can be a pain to track down later. If you know localization and internationalization are in your near-term plans, it’s best to use the provided localization helpers from the start and build out your
en.jsonfile (if English is your project’s default language). Note that you can also localize your field labels: just use an i18n key as your label. While brevity is tempting, it’s best to keep your keys meaningful so later maintainers know what they refer to. See static string localization for more information. Astro has its own localization patterns.“Global document” schema sprawl. Certain editable content is needed on essentially every page, and developers handle that by adding it to a special piece type module,
@apostrophecms/global. This is a great place forheaderandfooterareas, for instance. However, if this document’s schema of fields becomes too large, it negatively impacts the performance of every page. Don’t worry about adding simple string settings, but try to avoidrelationshipfields here, and if you do use them, make sure they have a projection.Forking our official modules. 99% of the time, you just don’t need to do this, and it creates a large maintenance burden for you. Apostrophe provides several accommodations to help you avoid this. For server side overrides, learn more about server-side events and
extendMethods. For browser side overrides, see customizing the admin UI.