Bundles
Prerequisite: Please follow this documentation of how to set up bundles in the hub.
To navigate in Bundles, PXP can be configured with buttons and a Table-of-content (TOC). In Apps, posts in Bundles also can be swiped. To set up, various configurations must be done explicitly.
Post Swiper
To leverage swipe functionality, we use a Swiper of Content Body Components (also referred to as the Post Swiper). Since the content body component can render both posts and bundles, this setup allows users to swipe through individual posts within a bundle.
The Post Swiper works on both Web and App, and the swiper configuration is pretty much the same for both. The main difference is the URL structure, which is handled by the URL resolver (see *URL Resolver Setup* below) and swiper configuration. You may create two separate views — one for Web and one for App — if they need to differ; in this documentation we use a single view for both.
{
"content": [
{
"content": {
"type": "content-body",
"contextKey": "context",
"id": "$context.context.id"
},
"dataSource": {
"data": "$context.posts",
"type": "context"
},
"type": "swiper",
"effect": "slide",
"loop": false,
"pathValue": {
"value": "$context.context.properties.slug",
"condition": {
"value": "$context.context.properties.slug",
"operation": "SET",
"AND": [
{ "value": "$context.context.properties.slug", "operation": "SET" },
{ "operation": "EQUALS_NOT", "value": "$context.device_type", "compareValue": "desktop" }
]
}
}
}
],
"name": "content"
}Configuration Overview
This configuration defines the following behavior:
- dataSource :Uses a context-based data source and reads its data from $context.posts, which contains the flattened list of posts provided through the url resolver (see url resolver config below).
- content: Renders each slide using a content-body component.
- contextKey: "context" exposes the post data to the component.
- id: "$context.context.id" binds each slide to its corresponding post.
- effect and loop:
- effect: "slide" enables the standard slide transition effect.
- loop: false disables continuous looping, making the swiper behave as a linear post reader.
- pathValue: Generates the SEO-friendly URL path segment from the post slug using $context.content.properties.slug. A condition is applied so that the value is only used when:
- the slug is defined (SET), and
- the current device is not desktop.
Applying this guard ensures that pathValue is evaluated against the active slide context rather than the parent Swiper context and prevents empty path segments from being added to the URL.
For more information, see the pathValue (PATH scope) section of the Swiper Component documentation.hnb
URL Resolver Setup
Configure the URL Resolver (see Dynamic URL Resolving ) so that the same view can be opened on both web and app platforms while using the appropriate URL structure for each environment.
This setup consists of two parts:
- dataToPathResolver — generates the outgoing URL for a piece of content.
- urlToViewResolvers — maps an incoming URL back to a view and its associated data.
To keep path definitions consistent, define a shared constant for path prefixes and reuse it across both resolvers:
- dataToPathResolver
dataToPathResolver maps content to the URL used to open it. The generated URL depends on the current platform.
App (platform !== 'web')
App URLs use content IDs because they are stable and available offline.
Supported URL formats:
- Bundle or standalone post: /{APP}/{id}
- Post within a bundle: /{APP}/{bundleId}?swiper-id={postId}
When opening a bundled post, the bundle is loaded and the Swiper uses the swiper-id query parameter (URL scope) to determine the initial slide.
Web (platform === 'web')
Web URLs use slugs to create human-readable and SEO-friendly URLs.
Supported URL formats:
- Bundle or standalone post: /{WEB}/{slug}
- Post within a bundle: /{WEB}/{bundleSlug}/{postSlug}
async function dataToPathResolver({ content, dataResolver }) {
if (!content) {
return;
}
const { platform } = dataResolver.contextService.metadata;
const isBundle = content.contentType === 'BUNDLE';
const isSinglePost = content.contentType === 'POST' && content.bundleId == null;
const isPostInBundle = content.contentType === 'POST' && content.bundleId != null;
// App logic — use ids (stable, offline-friendly)
if (platform !== 'web') {
if (isBundle || isSinglePost) {
return { paths: [`/${PATHS.APP}/${content.id}`] };
}
if (isPostInBundle) {
// open the post's issue bundle with this post as the initial id
return { paths: [`/${PATHS.APP}/${content.bundleId}?swiper-id=${content.id}`] };
}
return;
}
// Web logic — use slugs (human-readable / SEO)
if (isBundle || isSinglePost) {
return { paths: [`/${PATHS.WEB}/${content.properties.slug}`] };
}
if (isPostInBundle) {
const bundle = await dataResolver.findContentById(content.bundleId);
// bundleSlug/postSlug — the post slug is matched by the swiper's pathValue
return { paths: [`/${PATHS.WEB}/${bundle.properties.slug}/${content.properties.slug}`] };
}
}urlToViewResolvers
urlToViewResolvers maps incoming URLs back to the content view and provides the data required to render it.
Both web and app routes resolve to the same view and supply an identical viewContext containing:
- content — the resolved content item with bundled content included.
- posts — a flattened list of posts used as the Swiper data source ($context.posts).
To avoid duplication, the shared logic is extracted into a helper function.
// Shared: fetch a content (post or bundle) and build the view context
async function resolveContentView(contentId, dataResolver, source) {
try {
const content = await dataResolver.findContentById(contentId, { includeBundledContent: true });
const posts = content.contentType === 'BUNDLE' ? content.contents.map((c) => c.post) : [content];
return {
viewName: 'content',
viewContext: { content, posts },
};
} catch (error) {
console.error(`Error resolving ${source} content:`, error);
return { notFound: true };
}
}
const urlToViewResolvers = [
{
// Web content URLs: resolve based on slugs — /readWeb/:bundleSlug(/:postSlug)
pathPattern: `/${PATHS.WEB}/:contentSlugs+`,
async viewResolver({ match, resolvedData, dataResolver }) {
const mainContentSlug = match.params.contentSlugs[0];
const mainContent = resolvedData[mainContentSlug];
if (!mainContent || !mainContent.contents?.length) {
return { notFound: true };
}
return resolveContentView(mainContent.contents[0].id, dataResolver, 'web');
},
},
{
// App content URLs: resolve based on ids — /readApp/:contentId
pathPattern: `/${PATHS.APP}/:contentId`,
skipResolvedData: true, // required for offline
viewResolver({ match, dataResolver }) {
return resolveContentView(match.params.contentId, dataResolver, 'app');
},
}
];
Styling Buttons
To change the position of the nav buttons, you can override it in CSS as follows:
- App
// App css selectors
media (max-width: 767px) {
// default is absolute
.pxp-swiper .swiper-button {
position: fixed !important;
}
// default is 10px
.swiper-button-next {
right: 0px !important;
}
// default is 10px
.swiper-button-prev {
left: 0px !important;
}
}- Web (default CSS from Experience). It can be overwritten via SCSS.
// default Web css selectors
.bundle-button-prev {
left: 0;
right: auto;
}
.bundle-button-next {
right: 0;
left: auto;
}
.bundle-button-prev,
.bundle-button-next {
position: absolute;
top: 50%;
width: 27px;
height: 44px;
margin-top: -22px;
z-index: 10;
cursor: pointer;
background: {
size: 27px 44px;
position: center;
repeat: no-repeat;
}
}Important Remarks
A post can be associated with multiple bundles. Therefore, when opening a post, the expected behavior needs to be clearly defined. Below are the possible options:
- Open the post directly, without reference to any bundle.
- Open the post within the bundle that matches the bundleId associated with the post.
- Open a separate view or popup listing all possible bundles, allowing the user to choose.
- Display a small section within the component (e.g., content or search) that lists all related bundles, letting the user select one to open.
Currently, Option 2 used in this documentation.