Dynamic and async functionality in JAMstack

bnevilleoneill

Brian Neville-O'Neill

Posted on March 3, 2020

Dynamic and async functionality in JAMstack

Written by Deepak Gupta✏️

JAMstack sites are made up of static files that are served via CDN, but does that mean they give you a static experience? The answer is a big no.

There is a common misconception that the phrase “static site” describes a website that is inflexible or fixed. What it really means is that no server is required to serve the site.

A JAMstack site, at its core, is made up of prerendered files that are served statically and progressively enhanced via an API using JavaScript. UnlikePHP projects such as WordPress, we don’t need an intermediate step to generate HTML files on every browser request. Files are already stored as they are supposed to be rendered on the browser. These static files often contain JavaScript code that calls an API to do most of the dynamic stuff.

But doesn’t that mean we’re building a server? Not exactly. Since we’re using serverless functions, we don’t need to create a server in the true sense.

In this tutorial, we’ll show how JAMstack sites handle dynamic content and interactions. We’ll demonstrate some of the most common dynamic actions a JAMstack site will need to perform, including:

  1. Loading data asynchronously  —  We’ll create a page in which you type the name of a Pokémon to get details about it asynchronously
  2. Reading and writing to a database  —  We’ll use the serverless function and database-as-a-service (DBaaS) solution to dynamically read and write to a database
  3. Adding a login page (i.e., user authentication) to the above app

LogRocket Free Trial Banner

1. Loading data asynchronously

Let’s say you’re requesting some data from an API. Depending on the situation, the server might take some time to process the request while blocking the main thread, rendering the webpage unresponsive.

We can use callbacks, promises, and async/await (i.e., asynchronous JavaScript features) to perform long network requests without blocking the main thread.

JavaScript contains many asynchronous libraries. We’ll use the Fetch API to load our data.

Now let’s build a page that enables the user to search for details of Pokémon by name using the PokéAPI.

Step 1. Create a form with an input field and a search button JAMstack App Search Form Input Field and Button

<form>
   <label for="name">Get Pokemon Details</label>
   <input type="text" id="name" name="name" required />
   <button type="submit">Search</button>
</form>
Enter fullscreen mode Exit fullscreen mode

Step 2. Listen for the submit event on the button, call the API with the data in the input field, and cache the result

<script type = "module" >
  import displayResult from './displayResult.js';
  const form = document.querySelector('form');
  const handleSubmit = async event => {
  event.preventDefault();
  const name = form.elements['name'].value;
  let character;
  const fromCache = localStorage.getItem(name);
  if (fromCache) {
    character = JSON.parse(fromCache);
  } else {
    character = await fetch(
        `https://pokeapi.co/api/v2/pokemon/${name}`
      )
      .then(response => response.json())
      .catch(error => console.error(error));
    localStorage.setItem(name, JSON.stringify({
      name: character.name,
      moves: character.moves,
      weight: character.weight,
      height: character.height,
      types: character.types,
      sprites: character.sprites,
    }));
  }
  displayResult(character);
};
form.addEventListener('submit', handleSubmit);
</script>
Enter fullscreen mode Exit fullscreen mode

Note: We’re using a helper function called displayResult, which forms the shape of data from our API result and does some more checks on the DOM.

const createResultRow = container => character => {
  if (!'content' in document.createElement('template')) {
    console.error('your browser does not support the template element');
  }
// load the template for a result row
  const template = document.getElementById('search-result-template');
  const result = document.importNode(template.content, true);
  const types = character.types.map(({
    type
  }) => type.name).join(', ');
  const moves = character.moves.map(({
    move
  }) => move.name).join(', ');
// update the template content
  result.querySelector('.name').textContent = character.name;
  result.querySelector('.type').textContent = types;
  result.querySelector('.weight').textContent = character.weight;
  result.querySelector('.height').textContent = character.height;
  result.querySelector('.moves').textContent = moves;
// update the character image and add alt text
  const img = result.querySelector('.image');
  img.src = character.sprites.front_default;
  img.alt = character.name;
// add the new row to the results container
  container.appendChild(result);
};
export default character => {
  const searchResult = document.getElementById('search-result');
// remove any existing search results
  while (searchResult.firstChild) {
    searchResult.removeChild(searchResult.firstChild);
  }
// loop through the results and create a new listing for each character
  [character].map(createResultRow(searchResult));
};
Enter fullscreen mode Exit fullscreen mode

Step 3. Finally, show the result on the DOM JAMstack App Search Results

This is how we can load data asynchronously in a static state. See the demo in action on CodeSandbox.

2. Reading and writing to a database

You may be wondering, how will we read and write to a database if we haven’t set one up? Put simply, database-as-a-service (DBaaS) solutions eliminate the need to set up or host a database.

DBaaS tools automatically generate APIs to carry out CRUD operations on the database by simply uploading or creating the data type we want to store. For this tutorial, we’ll use Fauna to create a small email subscriber app (you’ll need to create a Fauna account before you proceed with the following steps).

Step 1. Create a database and name it Email
Step 2. Click Security from the left nav bar and select the database we just created Creating a Database in Fauna
Step 3. Once saved, store the secret key somewhere since it is shown only once
Step 4. Next, create a file with the extension .gql, name it schema.gql, and add the following code

type Email {
  id: String!
}
type Query {
  emails: [Email!] !
}
Enter fullscreen mode Exit fullscreen mode

Step 5. Upload this file in the GraphQL tab, and we’re ready to perform CRUD

Now we have a database and a highly scalable, securable GraphQL API at no cost.

We can use the custom URL with the secret key, which is provided by Fauna on the client side, to carry out the read and write operation. But since we can’t put our sensitive keys in the client-side code, we’ll use the serverless function to pass a private server key to Fauna for read and write access to the database. Serverless functions are critical for keeping private keys private, and they can easily handle expensive computing tasks from the client side.

The diagram below shows the final structure of our app. We’ll call the serverless function (Lambda) — in our case, we’ll use Netlify functions from our JAMstack App (HTML deployed on CDN). The serverless function will be responsible for holding our private keys and making a call to our database.

Final Structure of JAMstack App

Below is the code for our serverless functions that store email details to the database.

/* Import faunaDB sdk */
const faunadb = require('faunadb');
const key = 'your faunadb key goes here';
// your faunaDB key
const q = faunadb.query;
const client = new faunadb.Client({
  secret: key
});
// These are required for CORS
const headers = {
  'Access-Control-Allow-Origin': '*',
  'Content-Type': 'application/json',
  'Access-Control-Allow-Methods': '*',
};
exports.handler = (event, context, callback) => {
  return client.query(
    q.Create(
        q.Collection("Email"),
        { 
            data: {
                id: event.queryStringParameters.email
            }
        }
      )
  ).then((response) => {
    console.log("success", response)

    return callback(null, {
      statusCode: 200,
      headers,
      body: JSON.stringify(response)
    })
  }).catch((error) => {
    return callback(null, {
      statusCode: 400,
      headers,
      body: JSON.stringify(error)
    })
  })
}
Enter fullscreen mode Exit fullscreen mode

Deploy this function to any serverless support service. For more information on deploying a serverless function in Netlify, see the official documentation.

Email Form to Test DBaaS App

The HTML code will be similar to the first app we created; only the script code will change.

<script>
 const form = document.querySelector('form');
const handleSubmit = async event => {
 event.preventDefault();
 const email = form.elements['email'].value;
 fetch(`http://localhost:9000/.netlify/functions/create?email=${email}`)
  .then(res => res.json())
  .then(data => {
   const successHTML =
    document.querySelector('.email-msg').innerHTML = `
            Your email is sucessfully added ${data.data.id}!
            Thanks for being a part of testing! Cheers :) 
          `;
  });
}
form.addEventListener('submit', handleSubmit); 
</script>
Enter fullscreen mode Exit fullscreen mode

Replace the URL provided by the services with the localhost URL in the above function and add it to your HTML code.

The only change from the first app is the query string passed, which is our email from the client. If successful, the app will display a message in the email-msg class.

Successfully Submitted Email Form to Test DBaaS App

See the demo in action on CodeSandbox.

3. User authentication

Let’s examine how user authentication is done in JAMstack.

  1. Use a third-party service such as OAuth, AWS Cognito, or Netlify Identity to help implement an authentication system
  2. Use JSON Web Tokens (JWT) to exchange and verify user information between services
  3. Use a serverless function to validate the token and allow users to log into the app (we can also create a delegation token using a serverless function or Auth0 or use delegation tokens to authorize direct access to services from the client side)
  4. Write client-side code to manage the login and logout

For the sake of simplicity, we’ll use OAuth’s built-in script. We’ll call Google’s authorization server (assuming it as our serverless function), which will :

  • Handle redirects back to your application
  • Validate access tokens
  • Store the access token that the authorization server sends to your app and retrieves when the app subsequently makes authorized API calls

Let’s add a login page to the first Pokémon app we created (we can simply reuse the code for this stage).

For google authentication, create a file called google-authentication.js and add the following code.

var GoogleAuth;
const SCOPE = 'https://www.googleapis.com/auth/userinfo.email';
function handleClientLoad() {
  // Load the API's client and auth2 modules.
  // Call the initClient function after the modules load.
  gapi.load('client:auth2', initClient);
}
function initClient() {
  // Initialize the gapi.client object, which app uses to make API requests.
  // Get API key and client ID from API Console.
  // 'scope' field specifies space-delimited list of access scopes.
  gapi.client.init({
      'apiKey': 'your api key',
      'clientId': 'your client id',
      'scope': SCOPE
  }).then(function () {
    GoogleAuth = gapi.auth2.getAuthInstance();
// Listen for sign-in state changes.
    GoogleAuth.isSignedIn.listen(updateSigninStatus);
// Handle initial sign-in state. (Determine if user is already signed in.)
    var user = GoogleAuth.currentUser.get();
    setSigninStatus();
// Call handleAuthClick function when user clicks on
    //      "Sign In/Authorize" button.
    $('#sign-in-or-out-button').click(function() {
      handleAuthClick();
    });
    $('#revoke-access-button').click(function() {
      revokeAccess();
    });
  });
}
function handleAuthClick() {
  if (GoogleAuth.isSignedIn.get()) {
    // User is authorized and has clicked "Sign out" button.
    GoogleAuth.signOut();
  } else {
    // User is not signed in. Start Google auth flow.
    GoogleAuth.signIn();
  }
}
function revokeAccess() {
  GoogleAuth.disconnect();
}
function setSigninStatus(isSignedIn) {
  var user = GoogleAuth.currentUser.get();
  var isAuthorized = user.hasGrantedScopes(SCOPE);
  if (isAuthorized) {
    $('#sign-in-or-out-button').html('Sign out');
    $('#revoke-access-button').css('display', 'inline-block');
    $('#auth-status').html(`Welcome to the app - ${user.Qt.Ad}`);  
    document.getElementById('app').style.display = 'block';
} else {
    $('#sign-in-or-out-button').html('Sign In/Authorize');
    $('#revoke-access-button').css('display', 'none');
    $('#auth-status').html('You need to be authorised to use the app');
    document.getElementById('app').style.display = 'none';
  }
}
function updateSigninStatus(isSignedIn) {
  setSigninStatus();
}var GoogleAuth;
const SCOPE = 'https://www.googleapis.com/auth/userinfo.email';
function handleClientLoad() {
  // Load the API's client and auth2 modules.
  // Call the initClient function after the modules load.
  gapi.load('client:auth2', initClient);
}
function initClient() {
  // Initialize the gapi.client object, which app uses to make API requests.
  // Get API key and client ID from API Console.
  // 'scope' field specifies space-delimited list of access scopes.
  gapi.client.init({
      'apiKey': 'AIzaSyDJM2tpYAm_9za_sZHBajTKCezSoRalNtg',
      'clientId': '1021021275901-8fqh15chm5lgkfpc98m2iuu0n8iiq2j5.apps.googleusercontent.com',
      'scope': SCOPE
  }).then(function () {
    GoogleAuth = gapi.auth2.getAuthInstance();
// Listen for sign-in state changes.
    GoogleAuth.isSignedIn.listen(updateSigninStatus);
// Handle initial sign-in state. (Determine if user is already signed in.)
    var user = GoogleAuth.currentUser.get();
    setSigninStatus();
// Call handleAuthClick function when user clicks on
    //      "Sign In/Authorize" button.
    $('#sign-in-or-out-button').click(function() {
      handleAuthClick();
    });
    $('#revoke-access-button').click(function() {
      revokeAccess();
    });
  });
}
function handleAuthClick() {
  if (GoogleAuth.isSignedIn.get()) {
    // User is authorized and has clicked "Sign out" button.
    GoogleAuth.signOut();
  } else {
    // User is not signed in. Start Google auth flow.
    GoogleAuth.signIn();
  }
}
function revokeAccess() {
  GoogleAuth.disconnect();
}
function setSigninStatus(isSignedIn) {
  var user = GoogleAuth.currentUser.get();
  var isAuthorized = user.hasGrantedScopes(SCOPE);
  if (isAuthorized) {
    $('#sign-in-or-out-button').html('Sign out');
    $('#revoke-access-button').css('display', 'inline-block');
    $('#auth-status').html(`Welcome to the app - ${user.Qt.Ad}`);  
    document.getElementById('app').style.display = 'block';
} else {
    $('#sign-in-or-out-button').html('Sign In/Authorize');
    $('#revoke-access-button').css('display', 'none');
    $('#auth-status').html('You need to be authorised to use the app');
    document.getElementById('app').style.display = 'none';
  }
}
function updateSigninStatus(isSignedIn) {
  setSigninStatus();
}
Enter fullscreen mode Exit fullscreen mode

Don’t worry about the code; it’s just a modified sample from Google. We simply replaced the scope, API key, and clientId.

For the client-side code, we’ll edit the index.html file.

Step 1. Include the above script in the HTML code

<script src="./google-authentication.js"></script>
Enter fullscreen mode Exit fullscreen mode

Step 2. Add the login button above the main tag in the body

<center style="margin-top: 100px">
<button class="loginBtn loginBtn--google" id='sign-in-or-out-button'>
</button>
<button id="revoke-access-button" style="display: none; margin-left: 25px">Revoke access</button>
</center>
Enter fullscreen mode Exit fullscreen mode

Step 3. Set the display as none for the app and make it appear after login, which happens in the Google-authenticated script we added

<main id='app' style="display: none;">
Enter fullscreen mode Exit fullscreen mode

Note: We can do a serverless function call after authentication with the token or the user data to get the authenticated data we need. The above process is just a small test app to demonstrate how the authentication works.

JAMstack App Authentication Before Login

JAMstack App Authentication After Login

That’s all there is to it! If you want to see the full demo in action, head to CodeSandbox.

Conclusion

Skipping servers and using the JAMstack to build and deliver websites can save time, money, and headaches when it comes to DevOps. These are much more than static sites; with JAMstack,  your app can respond to user interactions, store data, handle user authentication, and just about anything else you’d want a modern website to do.


Plug: LogRocket, a DVR for web apps

 
LogRocket Dashboard Free Trial Banner
 
LogRocket is a frontend logging tool that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.
 
In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.
 
Try it for free.


The post Dynamic and async functionality in JAMstack appeared first on LogRocket Blog.

💖 💪 🙅 🚩
bnevilleoneill
Brian Neville-O'Neill

Posted on March 3, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related