This is the early access documentation preview for Custom Views. This documentation might not be in sync with our official documentation.
Developing a dynamic page extension
Standard pages (also known as static pages) in the Studio have a fixed URL.
Dynamic pages allow you to create classes of pages with dynamic URLs and load different data based on the URL or other circumstances.
The typical example for a class of dynamic pages are product detail pages: These follow a specific URL scheme and load different product data depending on the URL.
The dynamic page extension gives the full URL scheme power to you by applying the following algorithm in the API hub when resolving a specific URL path to a page:
- If the path matches a page folder URL, this page folder is served.
- The dynamic page extension is asked to resolve the path. If it can resolve the page, this page is served.
- An error is served.
You can create an arbitrary number of dynamic pages, but all of them are handled in a single dynamic page extension.
Prerequisites
A data source must be implemented first.
For our example, we're using the data source example from the developing a data source extension article.
1. Specify the dynamic page type
Each class of dynamic pages needs to be announced to Studio to make it configurable for Studio users. For this, you need to specify a schema. For example:
{"dynamicPageType": "example/star-wars-movie-page","name": "Star wars movie","category": "","icon": "stars","dataSourceType": "example/star-wars-movie","isMultiple": true}
You can store the schema.json
file wherever you like, because it's only required in the Studio and not by the code.
The dynamicPageType
uniquely identifies the class of pages and later connects data from the Studio to the executed code. name
, category
, and icon
are for illustrative/documentary purposes in the Studio.
The dataSourceType
determines the main data source on such a page. In this example, the data source type is example/star-wars-movie
, which we used in the developing a data source extension article. Every dynamic page in the class of example/star-wars-movie-pages
will contain a data source of this type which holds the data that belongs to the page.
The Studio will indicate that this data source is automatically available and Frontend components can use it even though it wasn't explicitly configured to exist.
There's no data source just for a dynamic page
Beware: You can't define a data source purely for use in dynamic pages. You always also need to implement the correct data source extension for it. The reason is that once a data source is known to the Studio, it can be placed on any page folder, even on non-dynamic pages.
The flag isMultiple
determines if there are many pages in this class of dynamic page (known as a rule in the Studio or if there can only be a single page. See the using dynamic pages in the Studio article for more information.
2. Create the dynamic page in the Studio
The Studio first needs to know that the class of dynamic pages actually exists. To do this, the schema needs to be created. When you open the Studio, make sure you're in the Production
environment, then follow the steps below:
- From the Studio homepage, or the from the left menu, go to Developer > Dynamic pages.
- Check if the page type exists, if not, click the Create schema button to open the schema editor.
- Input your schema into the JSON editor, click Validate, then Publish.
If you input the dynamicPageType
directly into the JSON editor, you don't need to add it in the required input box.
You can only access the dynamic page schema area in the Production
environment, but don't worry this dynamic page won't be available to the customers until the dynamic-page-handler
is updated to handle this particular path.
- Verify that the new page type has been added.
Once the class of dynamic pages is known by Studio, an actual page needs to be created. To do this:
From the Studio homepage, or the from the left menu, click Dynamic pages.
Enter a name for your page version, then click Save.
Click the blue add icon.
- Select the 1 layout element.
- Drag your Frontend component into the layout element.
- Click Save and you'll be taken back to the site builder.
- Click the more icon on your draft page version and select Make default.
Even though you've made the page live in the production environment, this dynamic page won't be available to the customers until the dynamic-page-handler
is updated to handle this particular path, explained in the next step.
3. Implement the dynamic page logic
The dynamic page handler extension point doesn't support multiple functions, but just a single one. That allows you to implement arbitrary routing depending on your needs. For this example, a simple matching with regular expressions is used directly in the index.ts
.
Executing API calls costs time and affects the performance of your website. Therefore, you should make as few calls as possible and execute them in parallel.
export default {'dynamic-page-handler': async (request: Request): Promise<DynamicPageSuccessResult | null> => {const starWarsUrlMatches = request.query.path.match(new RegExp('/movie/([^ /]+)/([^ /]+)'));if (starWarsUrlMatches) {return await axios.post<DynamicPageSuccessResult>('https://swapi-graphql.netlify.app/.netlify/functions/index',{query:'{film(id:"' +starWarsUrlMatches[2] +'") {id, title, episodeID, openingCrawl, releaseDate}}',}).then((response): DynamicPageSuccessResult => {return {dynamicPageType: 'example/star-wars-movie-page',dataSourcePayload: response.data,pageMatchingPayload: response.data,};});}return null;},// ...};
The dynamic page handler receives the Request
similar to an action extension. However, this Request
is always directed to /frontastic/page
and always contains a path
query. As its return value, the dynamic page handler needs to return a DynamicPageResult
, or null
when the page can't be handled.
The example matches the given path
against a URL schema like /movie/<slug>/<id>
which is a common pattern for any kind of dynamic page. <slug>
is an SEO component that puts a readable element into the URL while only the <id>
is meaningful to actually resolve the underlying data.
If the URL matches the given pattern, the corresponding movie is loaded and a DynamicPageSuccessResult
is returned. This result contains the class of dynamic page that was inferred by the code as dynamicPageType
. The API hub uses this identifier to resolve the page layout and settings from the configuration in the Studio. The dataSourcePayload
is made available as a magical data source on the corresponding page (remember that a dataSourceType
was defined previously in the schema).
The code used here to fetch the data for a movie is the same one as in the developing a data source extension article. In your actual code, you'd usually have a method/function encapsulating this code and call it in both places.
The pageMatchingPayload
is used to provide a specific version of the data source data that's used to match Studio rules (also known as FECL. In many cases, this payload is the same as the dataSourcePayload
. But if the dataSourcePayload
is large or rather complex, this field can be used to provide a simplified version for matching.
4. Test the dynamic pages
To test the dynamic page, a standard HTTP request to /frontastic/page
is used, which receives a path
that conforms to the URL schema matched by the dynamic page logic that was just implemented. For example:
curl -X 'GET' -H 'Accept: application/json' -H 'Commercetools-Frontend-Extension-Version: STUDIO_DEVELOPER_USERNAME' 'https://EXTENSION_RUNNER_HOSTNAME/frontastic/page?locale=en_US&path=/movie/star-wars-episode-4/ZmlsbXM6MQ=='
For information on the Commercetools-Frontend-Extension-Version
header and the extension runner hostname, see Main development concepts.
The request returns the page payload for the dynamic page which includes the special __master
data source:
{..."data": {"_type": "Frontastic\\Catwalk\\NextJsBundle\\Domain\\PageViewData","dataSources": {"__master": {"data": {"film": {"id": "ZmlsbXM6MQ==","title": "A New Hope","episodeID": 4,"openingCrawl": "It is a period of civil war.\r\nRebel spaceships, striking\r\nfrom a hidden base, have won\r\ntheir first victory against\r\nthe evil Galactic Empire.\r\n\r\nDuring the battle, Rebel\r\nspies managed to steal secret\r\nplans to the Empire's\r\nultimate weapon, the DEATH\r\nSTAR, an armored space\r\nstation with enough power\r\nto destroy an entire planet.\r\n\r\nPursued by the Empire's\r\nsinister agents, Princess\r\nLeia races home aboard her\r\nstarship, custodian of the\r\nstolen plans that can save her\r\npeople and restore\r\nfreedom to the galaxy....","releaseDate": "1977-05-25"}}}}}}
This data source is generated from the dataSourcePayload
returned by the dynamic page code. The Studio knows from the schema specification that a data source of the corresponding type is available on every dynamic page of that class.
5. Link to dynamic pages
commercetools Frontend doesn't know anything about the URL structure you implement in the dynamic page handler extension. So, our framework can't generate links for the corresponding pages. This needs to be part of your own code. There are generally 2 ways of how linking can be handled:
- The frontend code takes care of links directly (see client-side routing for dynamic pages)
- The backend provides URLs for linking to the frontend
Number 1 is simple and straightforward if you only use entirely custom code. In that case, you can create a client-side function that generates a proper URL from given data and use that.
Number 2 leaves control over URL paths to the backend. This is more central and suits better as page folder URLs are also generated from the backend. So, the Frontend components library use this way, and it's presented here.
By convention, the backend stores the path to link to a dynamic page of an entity in the special property _url
. To ensure every representation of the entity, it makes sense to extract the code into a dedicated function and re-use this function everywhere the entity is sourced:
interface MovieData {data: {film: {id: string;title: string;episodeID: number;openingCrawl: string;releaseDate: string;};};_url: string;}const loadMovieData = async (movieId: string): Promise<MovieData | null> => {return await axios.post<MovieData | null>('https://swapi-graphql.netlify.app/.netlify/functions/index',{query:'{film(id:"' +movieId +'") {id, title, episodeID, openingCrawl, releaseDate}}',}).then((response): MovieData => {console.log(response.data);return {...response.data,_url:'/movie/star-wars-episode-' +response.data.data.film.episodeID +'/' +response.data.data.film.id,} as MovieData;}).catch((reason) => {return null;});};
The code already contains the generated _url
parameter. The very same function needs to be used when fetching movie data to be displayed using a data source anywhere else (see the developing a data source extension article). Or, at least, the correct _url
parameter needs to be generated when loading data about the same entity in different ways.
In case internationalized URLs are required, the convention is to use the property _urls
which holds a hash-map mapping locale strings to URL paths. For example: { _urls: { 'en_US': '/p/foo/23', 'de_DE': '/p/bar/23' }
.
The updated dynamic page extension using this function now looks like this:
return default {'dynamic-page-handler': async (request: Request): Promise<DynamicPageSuccessResult | null> => {const starWarsUrlMatches = request.query.path.match(new RegExp('/movie/([^ /]+)/([^ /]+)'));if (starWarsUrlMatches) {return await loadMovieData(starWarsUrlMatches[2]).then((result: MovieData|null) : DynamicPageSuccessResult|null => {if (result === null) {return null}return {dynamicPageType: 'example/star-wars-movie-page',dataSourcePayload: result,pageMatchingPayload: result,} as DynamicPageSuccessResult;});}return null;},};
6. Redirect to the correct dynamic page
As mentioned before, dynamic page URLs typically contain SEO parts, which aren't significant to fetch the actual information, besides an identifier. That also means that the non-significant part of a URL can change (and be changed) arbitrarily without affecting the actual page. For example, the URLs below would all resolve to the very same page content.
/movie/star-wars-episode-4/ZmlsbXM6MQ==/movie/jar-jar-binks-for-president/ZmlsbXM6MQ==
This is bad, especially because search engines don't like duplicated content over time. The correct behavior to fix this issue is to redirect to the canonical URL. The dynamic page extension point allows you to do this by returning a DynamicPageRedirectResult
instead of a DynamicPageSuccessResult
:
export default {'dynamic-page-handler': async (request: Request): Promise<DynamicPageSuccessResult | DynamicPageRedirectResult | null> => {const starWarsUrlMatches = request.query.path.match(new RegExp('/movie/([^ /]+)/([^ /]+)'));if (starWarsUrlMatches) {return await loadMovieData(starWarsUrlMatches[2]).then((result: MovieData | null): DynamicPageSuccessResult | DynamicPageRedirectResult | null => {// ...if (request.query.path !== result._url) {console.log(request.query.path,result._url,request.query.path !== result._url);return {statusCode: 301,redirectLocation: result._url,} as DynamicPageRedirectResult;}// ...});}return null;},};
Running a test HTTP request with the example URL from above (mind the -i
-parameter for CURL to show the response headers):
curl -i -X 'GET' -H 'Accept: application/json' 'https://swiss-toby-multi-dyn-demo.frontastic.dev/frontastic/page?locale=de_CH&path=/movie/jar-jar-binks-for-president/ZmlsbXM6MQ=='
Now results in a redirect response:
HTTP/2 301...location: /movie/star-wars-episode-4/ZmlsbXM6MQ==...