Volto recipe for footer actions managed as site content

Managing the Footer as content is one of the common tasks on a Plone / Volto website. One typical approach is to designate some root folder, let’s say footer-links as a container for Link instances, and use those links as shortcuts to dedicated pages.

So, a footer component may look like this:

import React from 'react';
import { getContent } from '@plone/volto/actions';
import { useSelector } from 'react-redux';
import {UniversalLink} from '@plone/volto/components';

const Footer = () => {

  const footerLinks = useSelector((state) => state.content.subrequests?.footer?.data?.items || []);

  React.useEffect(() => {
    getContent('/footer-links', null, 'footer');
  }, []);

  return footerLinks.map((item) =>
    <UniversalLink item={item} key={item['@id']}>{item.title}</UniversalLink>)

This approach works, but it’s a bit naive. There may be unneeded network requests and, most important, the content of this footer is not included in the server-side rendered HTML.

If you’re willing to write some Python code, this may be a relatively straight-forward fix: write some expander that automatically inserts the links as a @component in the current content response, so it can be picked up by the Footer component.

There is a solution for this problem, in case we want to keep things pure frontend-level. Volto provides an extension mechanism for its SSR framework, the config.settings.asyncPropsExtenders.

Here’s how I did it for a multilingual website:

  config.settings.asyncPropsExtenders = [
    ...(config.settings.asyncPropsExtenders || []),
      path: '/',
      extend: (dispatchActions) => {
        const action = {
          key: 'footer',
          promise: ({ location, store }) => {
            // const currentLang = state.intl.locale;
            const bits = location.pathname.split('/');
            const currentLang =
              bits.length >= 2 ? bits[1] || DEFAULT_LANG : DEFAULT_LANG;

            const state = store.getState();
            if (state.content.subrequests?.[`footer-${currentLang}`]?.data) {

            const url = `/${currentLang}/footer-links`;
            const action = getContent(url, null, `footer-${currentLang}`);
            return store.dispatch(action).catch((e) => {
              // eslint-disable-next-line
                `Footer links folder not found: ${url}. Please create as folder
                named footer-links in the root of your current language`,
        return [...dispatchActions, action];

And my Footer component is now simply:

function Footer(props) {
  const currentLang = useSelector((state) => state.intl.locale);
  const footerLinks = useSelector(
    (state) =>
      state.content.subrequests?.[`footer-${currentLang}`]?.data?.items || [],

  return footerLinks.map((item, i) => (
    <UniversalLink key={`${item.id}-${i}`} item={item}>