Dynamic Web Story Creation from Raw Data: A Guide to Serving with Next.js Route Handler
Nitish Kumar Singh
Jan 9, 2024Hello 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 DatabaseID
, CollectionID
, ProjectID
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!