Dynamic Web Story Creation from Raw Data: A Guide to Serving with Next.js Route Handler

Nitish Kumar Singh

Jan 9, 2024

Hello developers! In this blog post we will explore how to create or render and generate an AMP-HTML file for a Web Story and serve (host) it using Next.js Route Handler.

To do this we use Next.js Route Handler to run server side code and To store Web Stories data we use Appwrite Database, and we host this website on Vercel. In this way we can host unlimited Web Stories without need of deployment on every Web Story posting.

Because of Next.js Caching system that is enabled by default for Route Handler, Our Web Stories serve with efficient performance.

Before starting, If you not familiar with AMP-HTML and how Web Stories created using it, what are rules to creating Web Stories then you can read my previous blog-post that explain all these things, and also familiarize your self with Appwrite Database, Next.js Route Handler, App Router and how to create a Next.js app, if not know about it.

So let's start, Open your existing Next.js app that usage App Router or if you not have or even you not know how to create one then follow Next.js Installation Guide and create one.

In this blog post we are going to serve web stories with url like https://example.com/web-stories/brief-title-of-web-story_story-id. Here brief-title-of-web-story is a message that explains waht about story is story-id is an id to fetch data from Appwrite.

To create path like our desired url, create folder structure like /app/web-stories/[path]/route.js. Now our main work is in route.js file. Paste below code in route.js file.

var origin;
export async function GET(req,{params}) {
  let url = new URL(req.url);
  origin = url.origin;
  const storyId = getWebStoryId(params);
  var status, body,statusText;
  if (storyId) {
    let res = ... // fetch data from Appwrite dtabase
    if (res.status === 200) {
      try {
        let storyInfo = await res.json();
        if (url.pathname === storyInfo.path) {
          status = 200; statusText = "Request Succussful";
          storyInfo.data = JSON.parse(storyInfo.data);
          storyInfo.ampMetadata = JSON.parse(storyInfo.ampMetadata);
          body = getDocument(storyInfo);
        }else { status = 400; statusText = "Bad Request"; body = getOtherHTML(400); }
      } catch (error) {
        status = 500; statusText = "Server Error"; body = getOtherHTML(500);
      }
    }else if(res.status === 404){
      status = 404; statusText = "Not Found"; body = getOtherHTML(404);
    }else{
      status = 500; statusText = "Server Error"; body = getOtherHTML(500);
    }
  }else {status = 400; body = "Bad Request"; body = getOtherHTML(400); }
  return new Response(body, { status:status, statusText:statusText, headers: { "Content-Type": "text/html" }});
}

In the above code, First we create a url, initialize origin and get storyId by calling a getWebStoryId method by passing params object get from Next.js as props. The code of getWebStoryId method is below.

const getWebStoryId = (params)=>{
  let path = params["path"];
  let i = path.lastIndexOf("_");
  if(i !==-1){
    let id = path.substring(i+1,i+21);
    if(id.length === 20) return id;
  }
  return undefined;
}

In params object, we get path segment brief-title-of-web-story_story-id as path property of params object because we created dynamic Route Handler by giving name of folder to [path]. From path we try to extract id of story, if get then return it otherwise undefined is returned.

Now back to first code block, we declare three variables status, statusText and body to store response code, text and data (generated amp-html code as string). If we not get storyId then initialize status, statusText and body for sending response as Bad (400) request (you can send as you want) and if get storyId then proceed further.

Fetch data from Appwrite and store fetched response in res. The following code show how we fetch data from Appwrite using REST API.

let res = await fetch(`https://cloud.appwrite.io/v1/databases/[DatabaseID]/collections/[CollectionID]/documents/${storyId}`,
{headers:{
    "X-Appwrite-Project": '[ProjectID]',
    "X-Appwrite-Key": "[API-Key]",
    "Content-Type": "application/json",
    "X-Appwrite-Response-Format": "1.4.0"
  }
});

Here DatabaseIDCollectionIDProjectID and API-Key you can get from Appwrite console. And storyId we have already extracted.

Now after getting response, we check for it's status and initialize status, statusText and body based on res's status code. If code is not 200 then get appropriate HTML string by calling getOtherHTML method by passing code. The below is code of getOtherHTML method. You can customize it as you want.

const getOtherHTML = (code) => {
  let htmlString = '<!DOCTYPE html>\n<html lang="en">\n<head>\n<meta charset="UTF-8">\n<meta name="viewport" content="width=device-width, initial-scale=1.0">\n<title>Error</title>\n</head>\n<body style="font-family: Arial, sans-serif; text-align: center; margin: 50px;">\n';

  switch (code) {
      case 400:
          htmlString += '<h1 style="color: #FF6347;">400 Bad Request</h1>\n';
          htmlString += '<p>It seems there was an issue with your request. This may be due to one of the following reasons: The URL path is not correct or does not match the expected format. The specific path does not contain a 20-character ID. The server failed to extract the ID from the path. Please review the URL and ensure it is correctly formed with the required information.</p>\n';
          break;

      case 404:
          htmlString += '<h1 style="color: #FF6347;">404 Not Found</h1>\n';
          htmlString += '<p style="color: #777;">The requested resource could not be found on the server.</p>\n';
          htmlString += '<p style="color: #777;">Please check the URL and try again.</p>\n';
          break;

      case 500:
          htmlString += '<h1 style="color: #FF6347;">500 Internal Server Error</h1>\n';
          htmlString += '<p style="color: #777;">An unexpected error occurred on the server.</p>\n';
          htmlString += '<p style="color: #777;">Please try again later.</p>\n';
          break;

      default:
          htmlString += '<h1 style="color: #FF6347;">Unexpected Error</h1>\n';
          htmlString += '<p style="color: #777;">An unexpected error occurred.</p>\n';
          break;
  }

  htmlString += '</body>\n</html>';
  return htmlString;
};

If code is 200 then we put our further code in try-catch so if we get any error then send it as server error. Next, we get JSON fetched from Appwrite in storyInfo. The storyInfo object contains properties that show in below image.

Here title, desc, img and path are title of story, description of story, cover photo of story and pathname of story like /web-stories/brief-title-of-web-story_story-id. Rest of properties is for generating amp-html, styles contains custom (amp-custom) style (CSS) of amp-html as string, scripts contains extra AMP scripts if any, ampMetadata contains Story metadata ( that used for SEO) as json object in string and data is our main data of story content in JSON string.

{
    "standalone": "",
    "title": "Title of Story",
    "publisher": "Website Name",
    "publisher-logo-src": "URL of website's logo",
    "poster-portrait-src": "URL of cover photo"
}
{
    "nodeType": 1,
    "nodeName": "AMP-STORY",
    "childNodes": [
        {
            "nodeType": 1,
            "nodeName": "AMP-STORY-PAGE",
            "attributes": {
                "id": "page1"
            },
            "childNodes": [
                {
                    "nodeType": 1,
                    "nodeName": "AMP-STORY-GRID-LAYER",
                    "attributes": {
                        "template": "vertical"
                    },
                    "childNodes": [
                        {
                            "nodeType": 1,
                            "nodeName": "H1",
                            "childNodes": [
                                {
                                    "nodeType": 3,
                                    "text": "Cats"
                                }
                            ]
                        },
                        {
                            "nodeType": 1,
                            "nodeName": "AMP-IMG",
                            "attributes": {
                                "src": "https://code.nkslearning.com/assets/cat.jpg",
                                "width": "320",
                                "height": "580",
                                "layout": "responsive"
                            }
                        },
                        {
                            "nodeType": 1,
                            "nodeName": "Q",
                            "childNodes": [
                                {
                                    "nodeType": 3,
                                    "text": "Dogs come when they're called. Cats take a message and get back to you. --Mary Bly"
                                }
                            ]
                        }
                    ]
                }
            ]
        }
    ]
}

Next in first code block, we check url.pathname === storyInfo.path, so Web Story serve only on one URL and that is we decided and stored in path property of storyInfo. what happened if we not check it, our story serve on unlimited URLs because we fetch story data based only on story-id that extracted from path. So URLs can be any prefix plus "_story-id".

If condition is false then send Bad request response (customize it as you want). And If true then we start our main work of generating AMP_HTML string by calling getDocument method with storyInfo as parameter but before it parse ampMatadata and data as JSON object and store it in storyInfo. Following is the code of getDocument method.

function getDocument(storyInfo) {
  return`<!DOCTYPE html>
  <html ⚡>
    <head>
      <meta charset="utf-8" />
      <meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1"/>
      <noscript>${getAmpBoilerplateNoScriptCSS()}</noscript>
      <style amp-boilerplate>${getAmpBoilerplateCSS()}</style>
      <style amp-custom>${storyInfo.styles}</style>
      ${getMetadata(storyInfo.title,storyInfo.desc,storyInfo.img,storyInfo.path,storyInfo.$createdAt,storyInfo.$updatedAt)}
      <script async src="https://cdn.ampproject.org/v0.js"></script>
      <script async custom-element="amp-story" src="https://cdn.ampproject.org/v0/amp-story-1.0.js"></script>
      ${storyInfo.scripts}
    </head>
    <body>
      ${getAmpStory(storyInfo.data,storyInfo.ampMetadata)}
    </body>
    
  </html>`;
}

Here is boilerplate of AMP-HTML and concatenate required data (in form of html string) in it by calling methods.The amp-custom style css and extra scripts join in boilerplate from getting styles and scripts properties of storyInfo. The following code show codes of getAmpBoilerplateNoScriptCSS and getAmpBoilerplateCSS methods.

const getAmpBoilerplateNoScriptCSS = ()=>{
return `<style amp-boilerplate>
  body {
    -webkit-animation: none;
    -moz-animation: none;
    -ms-animation: none;
    animation: none;
  }
</style>`;
}

const getAmpBoilerplateCSS = ()=>{
return `       
body {
  -webkit-animation: -amp-start 8s steps(1, end) 0s 1 normal both;
  -moz-animation: -amp-start 8s steps(1, end) 0s 1 normal both;
  -ms-animation: -amp-start 8s steps(1, end) 0s 1 normal both;
  animation: -amp-start 8s steps(1, end) 0s 1 normal both;
}
@-webkit-keyframes -amp-start {
  from {
    visibility: hidden;
  }
  to {
    visibility: visible;
  }
}
@-moz-keyframes -amp-start {
  from {
    visibility: hidden;
  }
  to {
    visibility: visible;
  }
}
@-ms-keyframes -amp-start {
  from {
    visibility: hidden;
  }
  to {
    visibility: visible;
  }
}
@-o-keyframes -amp-start {
  from {
    visibility: hidden;
  }
  to {
    visibility: visible;
  }
}
@keyframes -amp-start {
  from {
    visibility: hidden;
  }
  to {
    visibility: visible;
  }
}`;
}

The getMetadata method generate string that contains all metadata tags (you can customize it). The all params we pass in it are get from storyInfo object. I already discussed about all properties except $createdAt and $updatedAt, these two properties provide time of creation and modification of document by Appwrite by default. The following is code of getMetadata method.

const getMetadata = (title,desc, img, path,created,updated)=>{
  return`
  <link rel="canonical" href="${origin+path}">
  <title>${title}</title>
  <meta name="description" content="${desc}">
  <meta property="og:title" content="${title}">
  <meta property="og:description" content="${desc}">
  <meta property="og:url" content="${origin+path}">
  <meta property="og:site_name" content="NKS CODING LEARNINGS">
  <meta property="og:image" content="${img}">
  <meta property="og:type" content="article">
  <meta property="article:published_time" content="${created}">
  <meta property="article:modified_time" content="${updated}">
  <meta property="article:author" content="NITISH KUMAR SINGH">
  <meta name="twitter:card" content="summary_large_image">
  <meta name="twitter:title" content="${title}">
  <meta name="twitter:description" content="${desc}">
  <meta name="twitter:image" content="${img}">
  `;
}

The last method is getAmpStory that take data and ampMetadata as params and return our main content as amp-html string. Below is code of getAmpStory method.

const getAmpStory = (json,ampMetadata)=>{
  let htmlString = `<${json.nodeName.toLowerCase()}`;
  for (const attributeName in ampMetadata) {
      if (ampMetadata.hasOwnProperty(attributeName)) {
          htmlString += ` ${attributeName.toLowerCase()}="${ampMetadata[attributeName]}"`;
      }
  }
  htmlString += '>';

  json.childNodes.forEach((childJSON) => {
      const childHTML = getElementAsString(childJSON);
      htmlString += childHTML;
  });

  htmlString += `</${json.nodeName.toLowerCase()}>`;
  return htmlString;
}

Here, we will generate attributes of amp-story tag from ampMetadata object and generate it's amp-story-page childs with calling getElementAsString and getElementAsString called it self for it's childs. Below is code of getElementAsString method.

const getElementAsString = (json)=>{
  if (!json || typeof json !== 'object') {
      return '';
  }

  if (json.nodeType === 3) {
      return json.text || '';
  } else if (json.nodeType === 1) {
      let htmlString = `<${json.nodeName.toLowerCase()}`;

      if (json.attributes) {
          for (const attributeName in json.attributes) {
              if (json.attributes.hasOwnProperty(attributeName)) {
                  htmlString += ` ${attributeName.toLowerCase()}="${json.attributes[attributeName]}"`;
              }
          }
      }

      htmlString += '>';

      if (json.nodeName === 'svg') {
          htmlString += json.innerHTML || '';
      } else if (json.childNodes && json.childNodes.length > 0) {
          json.childNodes.forEach((childJSON) => {
              const childHTML = getElementAsString(childJSON);
              htmlString += childHTML;
          });
      }

      htmlString += `</${json.nodeName.toLowerCase()}>`;

      return htmlString;
  } else {
      return '';
  }
}

After all these stuff, we have status, statusText and body variables initialized with proper data means AMP_HTML string on success and the html that string describes error on any error. and finally retun Response to Next.js Route Handler.

return new Response(body, { status:status, statusText:statusText, headers: { "Content-Type": "text/html" }});

I hope you understand how we can be host Web Stories using Next.js Route Handler and learn something by reading this post. Happy Coding!

Published on Jan 9, 2024
Comments (undefined)

Read More