Implementing Sitecore Edit Mode for Nuxt.JS

Yurii Bondar
Yurii Bondar
Cover Image for Implementing Sitecore Edit Mode for Nuxt.JS

Implementing Sitecore Edit Mode for Nuxt.JS

Next.js is the most popular frontend framework these days and it works great with Sitecore. However, it's always good to have alternatives. Nuxt.js offers similar capabilities to Next.js, catering to developers who prefer Vue over React. Sitecore JSS has an official SDK for React, Next and Vue, but not for Nuxt. My colleague, Bogdan Druziuk, added base Nuxt support to Vue. Now, we will add Experience Editor support for Nuxt. This way, we can give content authors the same content creation experience in both frameworks.

In order to implement Edit Mode for Nuxt.js we will use a similar approach as for the Next.js and reuse Next.js SDK code when possible. This article describes in detail how Editing Mode works in Next.js. We will use the same architecture, except Preview Mode, since it doesn't exist in Nuxt.js.

Below is the diagram, describing how Nuxt.js Editing Mode should work.

diagram.png

Similar to Next.js, we will create an API route to handle POST requests from Sitecore with Layout Service and Dictionary data. This data will be passed to Editing Render Middleware, which will use sync-disk-cache library to save on disk temporary data, which later will be used to render the page with editing data.

The following steps describe in detail how you can implement the Editing Mode:

  1. Install the following packages:

    npm i @sitecore-jss/sitecore-jss node-html-parser sync-disk-cache
    
  2. Copy the Next.JS implementation of the Editing Mode: https://github.com/Sitecore/jss/tree/dev/packages/sitecore-jss-nextjs/src/editing

  3. Create a Server API route under /server/api/editing/render.ts for handling POST requests from Sitecore:

    import { defineEventHandler } from 'h3';
    import { EditingRenderMiddleware } from '../../../lib/editing/editing-render-middleware';
    
    export default defineEventHandler(async (event) => {
    	  const handler = new EditingRenderMiddleware().getHandler();
    	  return handler(event);
    });
    
  4. Modify EditingRenderMiddleware from Next.JS SDK to make it compatible with Nuxt (replace Next's req and res objects with H3 event object, remove Preview Mode, etc):

    import { AxiosDataFetcher} from '@sitecore-jss/sitecore-jss';
    import { parse } from 'node-html-parser';
    import type { EditingData } from './editing-data';
    import { editingDataService } from './editing-data-service';
    import type { EditingDataService } from './editing-data-service';
    import {
      QUERY_PARAM_EDITING_SECRET,
      QUERY_PARAM_PROTECTION_BYPASS_SITECORE,
      QUERY_PARAM_PROTECTION_BYPASS_VERCEL,
    } from './constants';
    import { getJssEditingSecret } from './utils';
    import { readBody } from 'h3';
    import type { H3Event, EventHandlerRequest } from "h3";
    
    export interface EditingRenderMiddlewareConfig {
      
      dataFetcher?: AxiosDataFetcher;  
      editingDataService?: EditingDataService;  
      resolvePageUrl?: (serverUrl: string, itemPath: string) => string;  
      resolveServerUrl?: (event: H3Event<EventHandlerRequest>) => string;
    }
    
    export class EditingRenderMiddleware {
      private editingDataService: EditingDataService;
      private dataFetcher: AxiosDataFetcher;
      private resolvePageUrl: (serverUrl: string, itemPath: string) => string;
      private resolveServerUrl: (event: H3Event<EventHandlerRequest>) => string;
      
      constructor(config?: EditingRenderMiddlewareConfig) {
        this.editingDataService = config?.editingDataService ?? editingDataService;
        this.dataFetcher = config?.dataFetcher ?? new AxiosDataFetcher();
        this.resolvePageUrl = config?.resolvePageUrl ?? this.defaultResolvePageUrl;
        this.resolveServerUrl = config?.resolveServerUrl ?? this.defaultResolveServerUrl;
      }
      
      public getHandler(): (event: H3Event<EventHandlerRequest>) => Promise<void> {
        return this.handler;
      }
     
      protected getQueryParamsForPropagation = (
        query: any
      ): { [key: string]: string } => {
        const params: { [key: string]: string } = {};
        if (query[QUERY_PARAM_PROTECTION_BYPASS_SITECORE]) {
          params[QUERY_PARAM_PROTECTION_BYPASS_SITECORE] = query[
            QUERY_PARAM_PROTECTION_BYPASS_SITECORE
          ] as string;
        }
        if (query[QUERY_PARAM_PROTECTION_BYPASS_VERCEL]) {
          params[QUERY_PARAM_PROTECTION_BYPASS_VERCEL] = query[
            QUERY_PARAM_PROTECTION_BYPASS_VERCEL
          ] as string;
        }
        return params;
      };
    
      private handler = async (event: H3Event<EventHandlerRequest>): Promise<any> => {
        const body = await readBody(event);
        const query = getQuery(event)
        const { method } = event; 
    
        const startTimestamp = Date.now();
    
        if (method !== 'POST') {
          event.node.res.setHeader('Allow', 'POST');
          event.node.res.statusCode = 405;
          return {
            html: `<html><body>Invalid request method '${method}'</body></html>`,
          };
        }
    
        // Validate secret
        const secret = query[QUERY_PARAM_EDITING_SECRET] ?? body?.jssEditingSecret;
        if (secret !== getJssEditingSecret()) {
          console.error(
            'invalid editing secret - sent "%s" expected "%s"',
            secret,
            getJssEditingSecret()
          );
          event.node.res.statusCode = 401;
          return {
            html: '<html><body>Missing or invalid secret</body></html>',
          };
        }
    
        try {
          // Extract data from EE payload
          const editingData = extractEditingData(body);
    
          // Resolve server URL
          const serverUrl = this.resolveServerUrl(event);
    
          // Get query string parameters to propagate on subsequent requests (e.g. for deployment protection bypass)
          const params = this.getQueryParamsForPropagation(query);
    
          // Stash for use later on (i.e. within getStatic/ServerSideProps).
          // Note we can't set this directly in setPreviewData since it's stored as a cookie (2KB limit)
          const previewData = await this.editingDataService.setEditingData(
            editingData,
            serverUrl,
            params
          );
    
          // Grab the preview cookies to send on to the render request
          const cookies = event.node.req.headers['Set-Cookie'] as string[] || [];
          cookies.push(`previewData=${previewData.key}`)
    
          // Make actual render request for page route, passing on preview cookies as well as any approved query string parameters.
          // Note timestamp effectively disables caching the request in Axios (no amount of cache headers seemed to do it)
          const requestUrl = new URL(this.resolvePageUrl(serverUrl, editingData.path));
          for (const key in params) {
            if ({}.hasOwnProperty.call(params, key)) {
              requestUrl.searchParams.append(key, params[key]);
            }
          }
          requestUrl.searchParams.append('timestamp', Date.now().toString());
          const pageRes = await this.dataFetcher
            .get<string>(requestUrl.toString(), {
              headers: {
                Cookie: cookies.join(';'),
              },
            })
            .catch((err) => {
              console.error(err);
              throw err;
            });
    
          let html = pageRes.data;
          if (!html || html.length === 0) {
            throw new Error(`Failed to render html for ${editingData.path}`);
          }
    
          // replace phkey attribute with key attribute so that newly added renderings
          // show correct placeholders, so save and refresh won't be needed after adding each rendering
          html = html.replace(new RegExp('phkey', 'g'), 'key');
    
          if (editingData.layoutData.sitecore.context.renderingType === "component") {
            // Handle component rendering. Extract component markup only
            html = parse(html).getElementById("editing-component")?.innerHTML || "";
            if (!html) throw new Error(`Failed to render component for ${editingData.path}`);
          }
    
          const responseBody = { html };
    
          // Return expected JSON result
          event.node.res.statusCode = 200;
          return responseBody;
        } catch (err) {
          const error = err as Record<string, unknown>;
          console.error(error);
    
          if (error.response || error.request) {
            // Axios error, which could mean the server or page URL isn't quite right, so provide a more helpful hint
            console.info(
              "Hint: for non-standard server or Nuxt route configurations, you may need to override the 'resolveServerUrl' or 'resolvePageUrl' available on the 'EditingRenderMiddleware' config."
            );
          }
          event.node.res.statusCode = 500;
          return {
            html: `<html><body>${error}</body></html>`,
          };
        }
      };
    
      private defaultResolvePageUrl = (serverUrl: string, itemPath: string) => {
        return `${serverUrl}/editing${itemPath}`;
      };
    
      private defaultResolveServerUrl = (event: H3Event<EventHandlerRequest>) => {
        return `${process.env.VERCEL ? 'https' : 'http'}://${event.headers.get('Host')}`;
      };
    }
    
    export function extractEditingData(payload: any): EditingData {
      if (!payload || !payload.args || !Array.isArray(payload.args) || payload.args.length < 3) {
        throw new Error(
          'Unable to extract editing data from request.'
        );
      }
    
      const layoutData = JSON.parse(payload.args[1]);
      const viewBag = JSON.parse(payload.args[2]);
      // Keep backwards compatibility in case people use an older JSS version that doesn't send the path in the context
      const path = layoutData.sitecore.context.itemPath ?? viewBag.httpContext.request.path;
    
      return {
        path,
        layoutData,
        language: viewBag.language,
        dictionary: viewBag.dictionary,
      };
    }
    
  5. Implement a page route that will be used for fetching rendered EE page. It will retrieve the editing cache key from the cookies and use it to get the editing data from the disk. Since this route will use a server library (sync-disk-cache), we want to make sure it will be accessed only on the server side, so we separated it from the base route and put in under pages\editing\[...path].vue

<template>
  <not-found v-if="notFound && !loading" :context="appState.sitecoreContext" />
  <route-loading v-else-if="loading" />
  <layout v-else :route="appState.routeData" />
</template>

<script>
import * as config from "../../temp/config";

import Layout from "../../Layout";
import NotFound from "../../NotFound";
import RouteLoading from "../../RouteLoading";
import { editingDataService } from "../../lib/editing/editing-data-service"

export default {
  name: "Page",
  data() {
    ...
  },
  async setup() {
    const app = useNuxtApp().vueApp;
    const cookie = useCookie("previewData")
    const editingDataResult = await useAsyncData(async () => {
      return editingDataService.getEditingData({ key: cookie._value });
    });

    const routeData = editingDataResult.data.value.layoutData;

    app.config.globalProperties.$jss.store.setSitecoreData(routeData);

    return { routeData };
  },
  created() {
    ...
  },
  components: {
    Layout,
    NotFound,
    RouteLoading,
  },
};
</script>
  1. Make sure you fetch the correct page from the Editing Render Middleware:

    private defaultResolvePageUrl = (serverUrl: string, itemPath: string) => {
    	return `${serverUrl}/editing${itemPath}`;
    };
    
  2. Update the site settings in Sitecore to include correct Server side rendering engine endpoint URL and ServerSideRenderingEngineApplicationUrl

sitecore-screenshot.png

Now when you open any page in Experience Editor, you should be able to use all editing features.

Conclusion

Meta-frameworks (Nuxt.js, Nuxt.js, Remix, etc.) became a standard in 2023. “Clean” React, Vue sites become a rare case. Nuxt.js is a great alternative to Next.js if you choose Vue. And it is quickly gaining popularity. The latest Sitecore JSS updates are focused on Next.js. It allows React-based sites to quickly get more abilities by upgrading to Next.js. But even if you chose Vue in the past, you should not throw your code and migrate to Next.js. You can add Nuxt.js by yourself. And now you can use the full power of Sitecore in your Nuxt websites, including editing capabilities.