Tutorial: My first site
Contents
A step-by-step introduction to building websites with the Enonic JS framework.
If you are primarily looking to use Enonic’s headless API. Visit the intro tutorial instead. |
Introduction
During this exercise you will:
-
set up a local Enonic developer environment
-
create an Enonic app
-
install and use Content Studio
-
learn about the structure of an Enonic app
-
learn about content types
-
setup pages and parts
-
use page templates
-
manage static assets
-
and then some…
Install Enonic CLI
The Command Line Interface is an essential tool for developers.
If you have npm
on your device, run this command:
npm install -g @enonic/cli
If not, here are some alternative ways to install Enonic CLI
To verify that the CLI has been installed, run enonic -v
. This should output the version of your installed CLI.
To see all available options, simply run enonic
.
To upgrade, use enonic latest . If there are new versions you will see instructions on how to upgrade. |
Create a sandbox
A sandbox is a local developer instance of our platform - Enonic XP. Create a sandbox by running this command in your terminal:
enonic sandbox create
Give it a name, i.e. myfirstsite
, and select the most recent version of Enonic XP from the list that appears.
Start the sandbox with this command:
enonic sandbox start --dev
Select the intro
sandbox from the list, and it will boot up in development mode.
Dev mode will automatically load changes in your code when developing. |
Create app
From a new terminal window, run the following command to create the application.
Use the default options when prompted. |
enonic project create -r tutorial-myfirstsite
The command uses the My first site Github repo as a starter (template) for the app. |
Project structure
The project folder created should now contain a basic app structure, looking something like this:
build.gradle (1)
settings.gradle
build/ (2)
code-samples/ (3)
docs/ (4)
src/
main/
resources/
import/ (5)
assets/ (6)
sites/
content-types/ (7)
country/
city/
parts/ (8)
pages/ (9)
1 | gradle files are used by the app build system |
2 | Contains output files produced by the build |
3 | Code samples that will be used in this tutorial |
4 | Contains the documentation you are reading now |
5 | Contains sample content that is imported when the app starts |
6 | Location for static assets |
7 | Two pre-defined content types, country and city |
8 | Part components - used to compose pages |
9 | Page components - root component for pages |
Building and Deploying
Assuming you did not change the default directory name myproject/
when creating the app - build and deploy the app with the following commands:
cd myproject enonic project deploy
Select the myfirstsite
sandbox from the list, and the app will use this from now on.
Look for a line like this in the sandbox log to verify that the app has started: 2019-04-09 13:40:40,765 INFO ... Application [com.example.myproject] installed successfully |
Install Content Studio
Content Studio is the editorial interface used to create and manage content. It is not a part of the core platform, but as you will see soon, it can easily be installed from Enonic Market.
-
Open the sandbox admin: http://localhost:8080/admin and click
Login without a user
. This will bring you to the Dashboard. -
Open the Applications app from the top right
XP menu → Applications
. -
Install Content Studio: click
Install
button in the menu bar, scroll down toContent Studio
(or use search) in the list of apps that appears and clickInstall
next to it.
Sample content
On the Dashboard
, a Content Studio widget should now have appeared. It shows that we already have some sample content available.

A content project called My First Site
andthe sample content gets imported when you application was started the first time.
Let’s have a closer look:
-
Open Content Studio, which is now available in
XP menu → Content Studio
. -
Select the
My First Site
project when prompted, and you should now see the imported content.

Content Types
Every content item has a specific a content type.
Enonic XP ships with a set of standard content types such as Shortcut
, Folder
, Site
and a range of Media Types
that handle different kind of files.
Additionally, an application may define custom content types. In our case, the content types Country
and City
already exist in the application.
In Content Studio, the City
content type looks something like this:

The content type is defined in a file, looking like this:
<content-type xmlns="urn:enonic:xp:model:1.0">
<display-name>City</display-name>
<description>Place where many people live</description>
<super-type>base:structured</super-type>
<form>
<input type="ImageSelector" name="photo">
<label>Photo</label>
<occurrences minimum="0" maximum="1"/>
</input>
<input type="TextLine" name="slogan">
<label>City slogan</label>
<occurrences minimum="0" maximum="1"/>
</input>
<input type="Long" name="population">
<label>Population</label>
<occurrences minimum="1" maximum="1"/>
<config>
<min>1</min>
</config>
</input>
</form>
</content-type>
Visit these links for more information about Content types and the schema system
Pages
At the moment, we have some content, but no preview or rendering. As a headless CMS, Enonic supports use 3rd party front-ends - but Enonic also has an an embedded JavaScript runtime, and a JS framework. This can be used to customize the platform, or, like in our case render pages for a website.
Pages are essentially composed from one or more components. Using the Enonic framework, each component will have a JavaScript controller which is responsible for rendering it.
Page component
The app includes a pre-defined page component. In addition to the controller, this component also uses an html template - aka the view in the Model View Controller (MVC) pattern. They look like this:
The JavaScript controller file must use the same name as the component directory. |
const portal = require('/lib/xp/portal'); // Import the portal library
const thymeleaf = require('/lib/thymeleaf'); // Import the Thymeleaf library
// Handle the GET request
exports.get = function(req) {
// Prepare the model that will be passed to the view
const model = {
displayName: portal.getContent().displayName
};
// Specify the template file to use
const view = resolve('hello.html');
// Return the merged view and model in the response object
return {
body: thymeleaf.render(view, model)
};
};
<!DOCTYPE html>
<html>
<head>
<title data-th-text="${displayName}">Sample title</title>
</head>
<body>
<h1 data-th-text="${displayName} + ' - we made it!'">Sample header</h1>
</body>
</html>
The view is plain HTML, but also uses a specific syntax known as the Thymeleaf templating language. The Enonic runtime also supports many other options, such as React, Mustache and Freemarker |
Create page
To actually have the hello
page component render something, it must be mapped to a content item:
-
Select the site content
Hello World
and click edit -
From the preview panel on the right, select the
Hello
page component in the list. Your changes will automatically save, and the page preview will render the result.

Regions
Content Studio’s page editor also supports adding more components to a page, using drag’n drop.
To support this, the page component must define one or more regions.
Complete the steps below to add a single region called main
to the hello
page component.
-
Start off by adding a component descriptor. The descriptor statically declares the region:
src/main/resources/site/pages/hello/hello.xml<page xmlns="urn:enonic:xp:model:1.0"> <display-name>Hello Regions!</display-name> <description>Drag'n drop enabled</description> <form/> <regions> <region name="main"/> </regions> </page>
-
Update your controller and view with the code below to support the new region:
src/main/resources/site/pages/hello/hello.jsvar portal = require('/lib/xp/portal'); // Import the portal library var thymeleaf = require('/lib/thymeleaf'); // Import the Thymeleaf library // Handle the GET request exports.get = function(req) { // Get the content that is using the page var content = portal.getContent(); // Prepare the model that will be passed to the view var model = { displayName: portal.getContent().displayName, mainRegion: content.page.regions.main } // Specify the view file to use var view = resolve('hello.html'); // Return the merged view and model in the response object return { body: thymeleaf.render(view, model) } };
src/main/resources/site/pages/hello/hello.html:<!DOCTYPE html> <html> <head> <title data-th-text="${displayName}">Sample title</title> </head> <body> <header> <h1 data-th-text="${displayName} + ' - with regions'">Sample header</h1> </header> <main data-portal-region="main"> <div data-th-if="${mainRegion}" data-th-each="component : ${mainRegion.components}" data-th-remove="tag"> <div data-portal-component="${component.path}" data-th-remove="tag"></div> </div> </main> <footer>My first site - powered by Enonic</footer> </body> </html>
Your sandbox should automatially pickup the changes, as we are running in dev mode.
-
Back in Content Studio, select, and edit one of the contry content items.
-
Activate the page editor (from the top right monitor icon) and select the
Hello
component.
You now have a page with a region. Try adding a text component to the region. It is available from the right hand side as drag-n-drop, or by using the right click menu.
Parts
You’re now ready to present some more information from the Country content type.
Rather than making another page component, let’s create a part
. Parts are essentially components that can be added to regions.
-
Create the folder
src/main/resources/site/parts/country/
in your project. -
Add the part controller and view files below to the folder:
src/main/resources/site/parts/country/country.jsvar portal = require('/lib/xp/portal'); var thymeleaf = require('/lib/thymeleaf'); // Handle the GET request exports.get = function(req) { // Get the country content as a JSON object var content = portal.getContent(); // Prepare the model object with the needed data from the content var model = { name: content.displayName, population: content.data.population || "Unknown", description: content.data.description || "Missing description", }; // Specify the view file to use var view = resolve('country.html'); // Return the merged view and model in the response object return { body: thymeleaf.render(view, model) } };
src/main/resources/site/parts/country/country.html<div> <div data-th-if="${population}" data-th-text="'Population: ' + ${population}">5 gazillions</div> <div data-th-if="${description}" data-th-text="${description}">Nice city indeed</div> </div>
-
When done, the part can be added to the page by drag’n dropping a part component from the right-hand-side, then select the
Country
component from the list.
Configurable part
Let’s try something slightly more advanced.
In this task, you’ll add a new part that does the following:
-
Lists cities within a country.
-
Supports configuation for image cropping format
-
Includes a real-time cropped image of each city
Complete the steps below to try it out:
-
Create the
City list
part by adding the controller and view files:src/main/resources/site/parts/city-list/city-list.jsvar contentLib = require('/lib/xp/content'); var portal = require('/lib/xp/portal'); var thymeleaf = require('/lib/thymeleaf'); // Specify the view file to use const view = resolve('city-list.html'); // Handle the GET request exports.get = function (req) { // Get the part configuration for the map const config = portal.getComponent().config; const countryPath = portal.getContent()._path; const componentPath = portal.getComponent().path; // Get all child item cities's const result = contentLib.query({ start: 0, count: 100, contentTypes: [ app.name + ':city' ], query: "_path LIKE '/content" + countryPath + "/*'", sort: "modifiedTime DESC" }); const cities = []; // Create a crop based on configuration const crop = config.crop || 'ORIGINAL'; let scale; if (crop == 'SQUARE') { scale = 'square(1080)'} else if (crop == 'WIDESCREEN') { scale = 'block(1080,300)'} else { scale = 'width(1080)'} if (result.hits.length > 0) { // Loop through the contents and extract the needed data result.hits.forEach((item)=>{ const city = {}; city.name = item.displayName; city.photo = item.data.photo ? portal.imageUrl({ id: item.data.photo, scale: scale, }) : null; const population = item.data.population ? 'population: ' + item.data.population : ''; city.caption = [item.data.slogan, population].filter(Boolean).join(' - '); cities.push(city); }) } // Specify the view file to use const model = {cities}; log.info('Model object %s', JSON.stringify(model, null, 4)); // Return the response object return { body: thymeleaf.render(view, model) }; };
src/main/resources/site/parts/city-list/city-list.html<div class="cities" style="min-height:100px;"> <h2>Cities</h2> <p data-th-if="${#lists.isEmpty(cities)}">No cities found</p> <div data-th-unless="${#lists.isEmpty(cities)}" class="city" data-th-each="city : ${cities}"> <h3 data-th-text="${city.name}">Utopia</h3> <figure> <img data-th-if="${city.photo}" data-th-src="${city.photo}"/> <figcaption data-th-if="${city.caption}" data-th-text="${city.caption}">Best city every</figcaption> </figure> <br/> </div> </div>
-
This time, also add a component descriptor. This descriptor includes a form (similar to a content type). The form allow editors to configure the part, once it is placed on the page.
src/main/resources/site/parts/city-list/city-list.xml<part xmlns="urn:enonic:xp:model:1.0"> <display-name>City list</display-name> <description>Show all cities in a country</description> <form> <input type="ComboBox" name="crop"> <label>Image crop</label> <occurrences minimum="0" maximum="1"/> <config> <option value="ORIGINAL">Original</option> <option value="WIDESCREEN">Widescreen</option> <option value="SQUARE">Square</option> </config> </input> </form> </part>
-
Add the new part to the page you created earlier.
-
Finally, configure the part by selecting it and changing the settings in the right hand panel.
The controller is also configured to log model passed to the view, check sandbox log after visiting a country page to see what is happening.

The images are scaled and cropped in real-time by the Image service. The part controller uses the imageUrl() function to create a link to the desired image. |
Page Templates
With our current approach, you would have to configure a new page for every country in the list. A slightly more efficient approach is to use page templates.
By creating a page template, and map it to a specific content types, all items of this type - i.e. countries - will be rendered by the page template.
Let’s create a page template from the country page you just made:
-
Edit the country page made in the previous step, in the visual editor - select the page and choose "Save as Page template" from the right hand component panel.
-
A new browser tab will open with the new template. Rename it and save.
The Supports
field defines which content type(s) the template will apply to.
Try clicking the other countries to verify that the template actually works as intended. |
Toggle template
Your originally edited country still uses the custom page you created - you can change it to use the new page template as well.
-
Edit the initial country, open the page editor and select the page.
-
From the right hand component view, change the "Template" setting from "Custom" to "Automatic"
For each content item, you can select a specific template, or customize the presentation at any time. |
Static assets
So far, the site is made up of dynamic HTML and images. Let’s have a look at how to handle static assets like graphics, CCS and JavaScript files.
Enonic supports an "out-of-the-box" solution for this through the Asset service. By simply placing files in the src/resources/assets
folder - they are instantly available on a specific URL.
Your application already includes the sample asset assets/mvp.css
. Let’s put it to work by adding the CSS to the page component:
Update your page component view with the following content:
<!DOCTYPE html>
<html color-mode="user">
<head>
<title data-th-text="${displayName}">Sample title</title>
<link rel="stylesheet" data-th-href="${portal.assetUrl({'_path=mvp.css'})}" href="../assets/mvp.css" type="text/css" media="all"/>
</head>
<body>
<header>
<h1 data-th-text="${displayName} + ' - with regions'">Sample header</h1>
</header>
<main data-portal-region="main">
<div data-th-if="${mainRegion}" data-th-each="component : ${mainRegion.components}" data-th-remove="tag">
<div data-portal-component="${component.path}" data-th-remove="tag"></div>
</div>
</main>
<footer>My first site - powered by Enonic</footer>
</body>
</html>
There are only two changes in this file, one line that includes the css file, and a small attribute on the html element to activate dark mode support.
If you’re using dark mode
, the result will look something like this:

Perfect cache
In production, every asset will include cache headers that allow CDNs and browsers to cache it - and never need to download it again.
How does it work? The generated asset URL will contain a prefix based on a signature of the files in the app. The signature will change with every new version of the app. The portal.assetUrl()
function uses this signature when generating the URL, ensuring that clients always get the most recent version.
When running in dev mode (like you do now), cache headers are deactivated |
Lib static
If you’re using advanced build-tools like Webpack, assets may be chunked and served incrementally. Changing the URL to all assets for every re-deploy is no longer optimal.
For this purpose, you can take full control over asset serving by adding Lib static to your app.
We will not go into details in this tutorial, but lib-static is pre-bundled with selected app starters like Webpack Starter and React4XP starter.
Go Online
Now that the setup of Hello World
site is completed, it’s time to publish.
-
Select the
Hello World
site in the content browse panel -
Select Publish Tree from the top right action menu. The Publishing Wizard will now appear
-
Several
In progress
items are blocking publishing - clickMark as ready
to fix this. -
click Publish!

What just happened? In Content Studio, you work in the draft branch. When publishing, the selected items are merged from draft to the master branch
You can see the published site and the master branch on this url: http://localhost:8080/site/myfirstsite/master/hello-world . |
Well done!
🎉 Congratulations on building your first Enonic site - The Enonic team.
Appendix
Below are some nice to know features and tricks:
Setting up vhosts
If you want to go live on a production server, you will need to configure a vhost. Vhosts map the internal XP URI to a public facing URL i.e. mydomain.com → /site/default/master/hello-world.
Read more about vhost configuration in the XP docs.
Adding more apps
A site can be created from a single app, but you may also add more. SEO Meta Fields, Google Tag Manager and Siteimprove are just a few of the many apps that can instantly add new features to your site.
To add an app, install an app from the XP menu → Applications
app. Once installed, edit your site and add the application to the list of linked apps.
Logging
While developing an app, it can be helpful to see the structure of objects returned by library functions.
A way to do this is by creating up a small utility script.
exports.log = function (data) {
log.info('Utilities log %s', JSON.stringify(data, null, 4));
};
Then, call the log function in any controller like the example below and then check the log after refreshing the page.
var util = require('utilities');
var content = portal.getContent();
util.log(content);
This function and many more are included in the Util Library