Dynamic Templates


Initial Setup

To implement "dynamic templates," Vactory uses a type called json_api_collection on the Drupal side. This allows exposing the results of any entity (Node, taxonomy, user, custom entity, etc.) in compliance with the JSON API standards.

Reference: https://www.drupal.org/docs/core-modules-and-themes/core-modules/jsonapi-module/api-overview

Here's an example configuration:

./modules/custom/my_custom_module/news/widgets/list/settings.yml

name: "List"
multiple: FALSE
category: "News"
enabled: TRUE
fields:
collection:
type: json_api_collection
label: "JSON:API"
options:
"#required": TRUE
"#default_value":
id: "vactory_news_list"
# The resource we want to retrieve (required)
resource: node--vactory_news
# The JSON API filters (required)
filters:
- fields[node--vactory_news]=drupal_internal__nid,path,title,field_vactory_news_theme,field_vactory_media,field_vactory_excerpt,field_vactory_date
- fields[taxonomy_term--vactory_news_theme]=tid,name
- fields[media--image]=name,thumbnail
- fields[file--image]=filename,uri
- include=field_vactory_news_theme,field_vactory_media,field_vactory_media.thumbnail
- page[offset]=0
- page[limit]=9
- sort[sort-vactory-date][path]=field_vactory_date
- sort[sort-vactory-date][direction]=DESC
- filter[status][value]=1

As we learned in the Static Templates section, we need to define a mapping template in the Next.js app:

./components/modules/contrib/news/NewsListWidget.jsx

export const config = {
id: "vactory_news:list",
}
const NewsListWidget = ({ data }) => {
console.log("news listing data", data)
return <>News listing</>
}
export default NewsListWidget

Displaying Content

We can now enhance our component to display the news items:

./components/modules/contrib/news/NewsListWidget.jsx

import React from "react"
import { normalizeNodes } from "./normalizer"
import { EmptyBlock } from "@/ui"
import { NewsCard } from "./NewsCard"
import { useDataHandling } from "@vactorynext/core/lib"
import { useCollectionFetcher } from "@vactorynext/core/hooks"
export const config = {
id: "vactory_news:list",
}
const NewsListWidget = ({ data }) => {
/**
* Custom hook for handling various data-related functionalities,
* including pagination, filters, translation, and context.
**/
const { context, filters } = useDataHandling(data)
// Fetch data based on params filters.
const collection = useCollectionFetcher({
type: "node",
bundle: "vactory_news",
initialPosts: context.nodes,
initialPostsCount: context.count,
params: filters,
})
// Format nodes
const posts = normalizeNodes(collection.posts)
// Loop through news items
return (
<div className="relative pb-4 pt-4 lg:pb-10 lg:pt-10">
{posts.length > 0 ? (
<div className="mx-auto grid gap-5 md:grid-cols-2 lg:grid-cols-3">
{posts.map((post) => (
<React.Fragment key={post.id}>
<NewsCard {...post} />
</React.Fragment>
))}
</div>
) : (
<EmptyBlock />
)}
</div>
)
}
export default NewsListWidget

The normalizer file would look like this:

./components/modules/contrib/news/normalizer.js

import get from "lodash.get"
import { stripHtml } from "@vactorynext/core/lib"
import truncate from "truncate"
export const normalizeNodes = (nodes) => {
return (
nodes?.map((post) => ({
id: post.drupal_internal__nid,
title: get(post, "title", null),
url: get(post, "path.alias", "#."),
excerpt: truncate(stripHtml(get(post, "field_vactory_excerpt.processed", "")), 100),
category: get(post, "field_vactory_news_theme", [])
.map((term) => {
return {
name: term.name,
slug: term?.term_2_slug || "",
}
})
.filter((t) => typeof t !== "undefined"),
image: {
src: post?.field_vactory_media?.thumbnail?.uri?.value?._default,
width: post?.field_vactory_media?.thumbnail?.meta?.width,
height: post?.field_vactory_media?.thumbnail?.meta?.height,
},
image_alt: post?.field_vactory_media?.thumbnail?.meta?.alt,
date: get(post, "field_vactory_date", null),
hasFlag: post?.has_flag,
isFlagged: post?.is_flagged,
})) || []
)
}

Layout Switching

A common requirement for listings is to allow users to switch between different layout views (grid and list). Vactory provides a custom hook to handle this:

./components/modules/contrib/news/NewsListWidget.jsx

import { useListingLayout } from "@vactorynext/core/lib"
const NewsListWidget = ({ data }) => {
// Switch Layout functionality
const { listingLayout, switchLayout } = useListingLayout()
// Rest of your component...
}

Include the LayoutSwitcher component from the UI package:

import { LayoutSwitcher } from "@/ui"
// Then inside your component render:
;<LayoutSwitcher listingLayout={listingLayout} switchLayout={switchLayout} />

Now adapt your rendering based on the selected layout:

<div
className={vclsx(
"mx-auto gap-5",
listingLayout === "grid" ? "grid md:grid-cols-2 lg:grid-cols-3" : "flex flex-col"
)}
>
{posts.map((post, index) => (
<NewsCard {...post} listingLayout={listingLayout} />
))}
</div>

Implementing Pagination

To add pagination to your dynamic content, import the Pagination component from the UI package:

./components/modules/contrib/news/NewsListWidget.jsx

import { Pagination, Container } from "@/ui"

Create a reference for scrolling and extract pagination info from the custom hook:

const {
scrollRefPagination,
defaultPageLimit,
pager,
setPager,
filters,
current_page_alias,
} = useDataHandling(data)

Create a URL pattern for pagination:

// Create path with page parameter
const nodeAliasPath = `${current_page_alias}?page={page}`
// Shallow URL for paginations element's href
const [paginationShallowUrl, setPaginationShallowUrl] = useState(nodeAliasPath)

Now add the Pagination component:

{
parseInt(collection.count) > parseInt(filters?.page?.limit || defaultPageLimit) && (
<Container className="px-4 pb-4 sm:px-6 lg:px-8 lg:pb-8">
<Pagination
baseUrl={`${paginationShallowUrl.replace(/page=\d+/, "page={page}")}`}
contentRef={scrollRefPagination}
pageSize={filters?.page?.limit || defaultPageLimit}
current={pager}
total={collection.count}
isLoading={!collection.isLoading}
onChange={(page) => setPager(page)}
id="news-pagination"
/>
</Container>
)
}

Implement the URL update mechanism:

import { useUpdateEffect } from "@vactorynext/core/hooks"
// Inside your component:
useUpdateEffect(() => {
setFilters((prev) => {
let filters = {
...prev,
}
// Update pager
filters.page = {
...filters.page,
offset: (pager - 1) * (filters?.page?.limit || defaultPageLimit),
}
return filters
})
// Update URL
let newUrl = nodeAliasPath.replace("{page}", pager)
setShallowUrl(newUrl)
setPaginationShallowUrl(newUrl)
}, [pager])
// Update page URL using shallow routing
useUpdateEffect(() => {
router.push(shallowUrl, undefined, { shallow: true })
}, [shallowUrl])

Advanced Filtering

Vactory provides a comprehensive filtering system with form handling and URL management:

1. Adding Filter Form with react-hook-form

First, import the required components:

import { useForm } from "react-hook-form"
import { SelectNative, Button, FilterWrapper } from "@/ui"
import { UseListingFilter } from "@vactorynext/core/lib"

Set up form state and default values:

// Generate default values for theme filter
const allThematic = generateDefaultValues(
"vactory_news_theme",
t("Toutes les thématiques"),
context
)
// Extract term slug from node alias and convert it to term ID
const defaultTheme = generateIdsFromTermsSlug(
systemRoute?._query?.theme,
context.terms.vactory_news_theme,
allThematic.id
)
const defaultSort = router?.query?.sort || "desc"
const [selectedTheme, setSelectedTheme] = useState(defaultTheme)
const [sortedValue, setSortedValue] = useState(defaultSort)
// Set up the form
const { handleSubmit, reset, control } = useForm({
defaultValues: {
theme: defaultTheme,
sort: defaultSort,
},
})

Implement form submission handlers:

// Submit filter form
const submitFilterForm = (data) => {
// Get the value of the selected filter
const selectedTheme = context.terms?.vactory_news_theme.find((theme) => {
return theme.id === data?.theme
})
// Track filter usage in dataLayer if needed
dlPush("filter_select", {
Thématique: selectedTheme.label,
"Type contenu": "Actualités",
})
if (showFilters) {
setshowFilters(false)
}
setSelectedTheme(data?.theme)
setSortedValue(data?.sort)
setPager(1)
}
// Reset filter form
const resetFilterForm = () => {
reset({
theme: allThematic.id,
sort: "desc",
})
if (showFilters) {
setshowFilters(false)
}
setPager(1)
setSelectedTheme(allThematic.id)
setSortedValue("desc")
}

Now create the filter form UI:

<form onSubmit={handleSubmit(submitFilterForm)}>
{/* Mobile filters */}
<FilterWrapper showFilters={showFilters} setshowFilters={setshowFilters}>
<div className="mb-6 flex flex-col gap-5">
<UseListingFilter
filters={[
{
type: "select",
componentType: SelectNative,
name: "theme",
id: "theme",
label: t("Nx:Thématique"),
variant: "filter",
list: context.terms?.vactory_news_theme,
position: 1,
},
{
type: "select",
componentType: SelectNative,
name: "sort",
id: "sort",
label: t("Nx:Filtrer par date"),
variant: "filter",
list: sortingList,
position: 2,
},
]}
control={control}
/>
</div>
<div className="flex flex-row items-center justify-center gap-4">
<Button id="news-submit" type="submit" variant="primary">
{t("Nx:Appliquer")}
</Button>
<Button id="news-reset" type="button" onClick={resetFilterForm} variant="secondary">
{t("Nx:Renitialiser")}
</Button>
</div>
</FilterWrapper>
{/* Desktop filters */}
<div className="hidden flex-col space-y-4 md:flex md:flex-row md:space-x-4 md:space-y-0">
<UseListingFilter
filters={[
{
type: "select",
componentType: SelectNative,
name: "theme",
id: "theme",
label: t("Nx:Thématique"),
variant: "filter",
list: context.terms?.vactory_news_theme,
position: 1,
},
{
type: "select",
componentType: SelectNative,
name: "sort",
id: "sort",
label: t("Nx:Filtrer par date"),
variant: "filter",
list: sortingList,
position: 2,
},
]}
control={control}
/>
<div className="flex flex-row items-center justify-center gap-4">
<Button id="news-submit" type="submit" variant="primary">
{t("Nx:Appliquer")}
</Button>
<Button id="news-reset" type="button" onClick={resetFilterForm} variant="secondary">
{t("Nx:Renitialiser")}
</Button>
</div>
</div>
</form>

2. Updating Filters and URLs

Update the URL handling to include filters:

// Create a more complex URL pattern with filters
const firstQuerySeparator = pager === 1 ? "?" : "&"
const secondQuerySeparator =
pager !== 1 && sortedValue === "desc" && listingLayout === "grid" ? "?" : "&"
const nodeAliasPath = `${current_page_alias}/{theme}?page={page}${firstQuerySeparator}sort={sort}${secondQuerySeparator}display={display}`
// Generate default URL
const defaultUrl = getDefaultUrl(
nodeAliasPath,
{
theme: selectedTheme === allThematic.id ? allThematic.id : selectedTheme,
sort: sortedValue === defaultSort ? "" : sortedValue,
display: listingLayout === "grid" ? "" : listingLayout,
},
[selectedTheme],
context
)
// Update URL handling function
const updatePrettyPath = () => {
// Update pretty path URL
let newNodeAliasPath =
selectedTheme === allThematic.id
? nodeAliasPath.replace("/{theme}", "")
: nodeAliasPath.replace(
"{theme}",
generateTermsSlugFromIds(
selectedTheme,
context.terms.vactory_news_theme,
allThematic.id
)
)
newNodeAliasPath = newNodeAliasPath.replace("{page}", pager)
if (listingLayout === "grid") {
newNodeAliasPath = removeQueryParamValue(newNodeAliasPath, "display={display}")
}
newNodeAliasPath = newNodeAliasPath.replace("{display}", listingLayout)
newNodeAliasPath =
sortedValue === defaultSortEver
? removeQueryParamValue(newNodeAliasPath, "sort={sort}")
: newNodeAliasPath.replace("{sort}", sortedValue)
setShallowUrl(
pager === 1
? newNodeAliasPath.replace(/[?&]page=1\b/, "").replace("&sort", "?sort")
: newNodeAliasPath.replace(/[?&]page=1\b/, "")
)
setPaginationShallowUrl(newNodeAliasPath)
}

Update the filter and pager effect:

useUpdateEffect(() => {
updatePrettyPath()
setFilters((prev) => {
let filters = {
...prev,
}
if (!selectedTheme || selectedTheme === allThematic.id) {
// Try to delete previously set theme filters
delete filters?.filter?.theme
} else {
// Add a theme filter
filters.filter.theme = {
condition: {
path: "field_vactory_news_theme.drupal_internal__tid",
operator: "=",
value: selectedTheme,
},
}
}
// Update pager
filters.page = {
...filters.page,
offset: (pager - 1) * (filters?.page?.limit || defaultPageLimit),
}
// Update sort
if (sortedValue == "popularite") {
filters.sort = {
"sort-popularite": {
direction: "DESC",
path: "field_node_count_view",
},
}
} else {
filters.sort = {
"sort-vactory-date": {
path: "field_vactory_date",
direction: sortedValue,
},
}
}
return filters
})
}, [selectedTheme, sortedValue, pager, listingLayout])

Animations and Transitions

Improve the user experience with animations using Framer Motion:

import { motion } from "framer-motion"
import { ParentTransition, ChildTransition } from "@/ui"
// Then in your component:
;<motion.div
variants={ParentTransition}
initial={"initial"}
className={vclsx(
"mx-auto gap-5",
listingLayout === "grid" ? "grid md:grid-cols-2 lg:grid-cols-3" : "flex flex-col"
)}
key={posts.reduce((prev, curr) => prev + curr.id, "")}
>
{posts.map((post, index) => (
<motion.div
key={post.id}
variants={ChildTransition(index)}
initial="initial"
whileInView="animate"
viewport={{ once: true, amount: 0.2 }}
>
<NewsCard {...post} listingLayout={listingLayout} />
</motion.div>
))}
</motion.div>

Loading State

Add a loading overlay to improve the user experience during data fetching:

import { LoadingOverlay } from "@/ui"
// Inside your component:
<LoadingOverlay active={collection.isLoading} spinner={true}>
<div className="relative pb-4 pt-4 lg:pb-10 lg:pt-10">
{posts.length > 0 ? (
// Your content
) : (
<EmptyBlock />
)}
</div>
</LoadingOverlay>

The JSON API Collection Alter Hook

We'll face a problem with our news listing when /en/news yields the same results as /en/news?page=2, /en/news?page=3, etc.

To understand the data flow in Next.js, see this diagram:

Next.js data flow

Drupal always prepares the news listing data based on the filters in settings.yml, regardless of the page number in the URL.

To solve this, we implement hook_json_api_collection_alter(&$filters, &$context) on the Drupal side. First, assign a unique ID to your json_api_collection item:

./modules/custom/my_custom_module/news/widgets/list/settings.yml

collection:
type: json_api_collection
label: 'JSON:API'
options:
'#required': TRUE
'#default_value':
id: "vactory_news_list"
# The resource we want to retrieve (required)
resource: node--vactory_news
# The JSON API filters (required)
filters:
- fields[node--vactory_news]=drupal_internal__nid,path,title,field_vactory_news_theme,field_vactory_media,field_vactory_excerpt,field_vactory_date
- fields[taxonomy_term--vactory_news_theme]=tid,name
- fields[media--image]=name,thumbnail
- fields[file--image]=filename,uri
- include=field_vactory_news_theme,field_vactory_media,field_vactory_media.thumbnail
- page[offset]=0
- page[limit]=9
- sort[sort-vactory-date][path]=field_vactory_date
- sort[sort-vactory-date][direction]=DESC
- filter[status][value]=1
# Additional data to fill filters on NextJS side (optional)
vocabularies:
vactory_news_theme: vactory_news_theme

Then implement the hook to override filters based on the page parameter:

./modules/custom/my_custom_module/my_custom_module.module

function hook_json_api_collection_alter(&$filters, &$context) {
if ($context['id'] === 'vactory_news_list') {
$query = \Drupal::request()->query->get("q");
if (empty($query)) {
return;
}
if (isset($query["page"])) {
$filters["page[offset]"] = intval($query["page"]) > 0 ? (intval($query["page"]) - 1) * $filters["page[limit]"] : 0;
}
}
}

Creating Pretty URLs

Currently, we use query parameters like /en/news?theme=annonce&page=1. But what if we could create more SEO-friendly URLs like /en/news/annonce?page=1?

First, understand how Next.js processes URLs:

Next.js path info flow

⚠️
The challenge is that Drupal doesn't consider /en/news/annonce to reference the same node as /en/news. It treats these as separate entities.

The Vactory Starter Kit solves this by configuring /news/{theme} to point to the same node as /news:

  1. Visit /admin/config/system/vactory_router

  2. Add a new route

  3. Fill in these values:

    • Libellé: News - Pretty URL SEO (EN)
    • Chemin: /node/{nid}
    • Alias: /news/{theme}

Vactory Router configuration

With this configuration complete, your pretty URLs will work correctly.

Pre-filtered Blocks/Listings

You can create pre-filtered content blocks by adding an optional_filters key:

./modules/custom/my_custom_module/news/widgets/list/settings.yml

name: 'List'
multiple: FALSE
category: 'News'
enabled: TRUE
fields:
collection:
type: json_api_collection
label: 'JSON:API'
options:
'#required': TRUE
'#default_value':
id: "vactory_news_list"
# The resource we want to retrieve (required)
resource: node--vactory_news
# The JSON API filters (required)
filters:
- fields[node--vactory_news]=drupal_internal__nid,path,title,field_vactory_news_theme,field_vactory_media,field_vactory_excerpt,field_vactory_date
- fields[taxonomy_term--vactory_news_theme]=tid,name
- fields[media--image]=name,thumbnail
- fields[file--image]=filename,uri
- include=field_vactory_news_theme,field_vactory_media,field_vactory_media.thumbnail
- page[offset]=0
- page[limit]=9
- sort[sort-vactory-date][path]=field_vactory_date
- sort[sort-vactory-date][direction]=DESC
- filter[status][value]=1
optional_filters:
vactory_news_theme: taxonomy_term
The syntax for optional_filters is "bundle: entity_type".

This adds an autocomplete field to pre-filter data:

Pre-filtered block configuration

Finally, implement the hook to apply the pre-filter:

./modules/custom/my_custom_module/my_custom_module.module

/**
* Implements hook_json_api_collection_alter().
*/
function hook_json_api_collection_alter(&$filters, &$context) {
if ($context['id'] === 'vactory_news_list') {
if ($filters['optional_filters_data']['taxonomy_term']['vactory_news_theme']) {
$filters["filter[internal_thematic][condition][path]"] = "field_vactory_news_theme.drupal_internal__tid";
$filters["filter[internal_thematic][condition][operator]"] = "=";
$filters["filter[internal_thematic][condition][value]"] = $filters['optional_filters_data']['taxonomy_term']['vactory_news_theme'];
}
}
}

Using EntityQueue

Entity queues let you manually order content:

  1. Visit admin/structure/entityqueue

  2. Define your entity queue

  3. Update your widget configuration:

./modules/custom/my_custom_module/news/widgets/list/settings.yml

name: 'List'
multiple: FALSE
category: 'News'
enabled: TRUE
fields:
collection:
type: json_api_collection
label: 'JSON:API'
options:
'#required': TRUE
'#default_value':
id: "vactory_news_list"
# The resource we want to retrieve (required)
resource: node--vactory_news
# The JSON API filters (required)
entity_queue: vactory_news_block_three_columns
entity_queue_field_id: drupal_internal__nid
filters:
- fields[node--vactory_news]=drupal_internal__nid,path,title,field_vactory_news_theme,field_vactory_media,field_vactory_excerpt,field_vactory_date
- fields[taxonomy_term--vactory_news_theme]=tid,name
- fields[media--image]=name,thumbnail
- fields[file--image]=filename,uri
- include=field_vactory_news_theme,field_vactory_media,field_vactory_media.thumbnail
- page[offset]=0
- page[limit]=9
- sort[sort-vactory-date][path]=field_vactory_date
- sort[sort-vactory-date][direction]=DESC
- filter[status][value]=1
  • entity_queue: The entity queue ID
  • entity_queue_field_id: The field used for filtering the data

Working with NodeQueue

Node Queue allows users to collect nodes in an arbitrarily ordered list.

To implement Node Queue, use the node_queue type on the Drupal side:

./modules/custom/my_custom_module/news/widgets/node_queue/settings.yml

name: "Node Queue"
multiple: TRUE
category: "Node Queue Categorie"
enabled: TRUE
fields:
nodes:
type: node_queue
label: "Node Queue"
options:
"#default_value":
id: "vactory_news_list"
resource: node--vactory_news
filters:
- fields[node--vactory_news]=drupal_internal__nid,path,title,field_vactory_news_theme,field_vactory_media,field_vactory_excerpt,field_vactory_date
- fields[taxonomy_term--vactory_news_theme]=tid,name
- fields[media--image]=name,thumbnail
- fields[file--image]=filename,uri
- include=field_vactory_news_theme,field_vactory_media,field_vactory_media.thumbnail
- page[offset]=0
- page[limit]=9
- sort[sort-vactory-date][path]=field_vactory_date
- sort[sort-vactory-date][direction]=DESC
- filter[status][value]=1

In the back office, this adds a "Noeuds" field for selecting multiple nodes:

Node Queue interface

The Noeuds input is a multiple selection field.