An Instagram 'Best Nine' App with Simple JavaScript

I thought about naming this article "Building an Instagram 'Best Nine' App in 200 Lines of JavaScript", but 'lines of code' is a stupid metric (imo) and the title would have been click-bait more than anything. Anyways, this article is about how to build an Instagram 'Best Nine' app with plain, modern JS (no frameworks or other packages).

What Is A 'Best Nine' App?

If you're not familiar with 'Best Nine' - it's a collage (or grid) of your nine most-liked Instagram posts for a given time period. There are several popular web and mobile apps for creating Best Nine grids available. Here's my Best Nine from 2019:

What Are We building?

Our Best Nine app will do the same as all the others - create a grid of a user's top Instagram posts - but it will work a bit differently. The app will run entirely in the browser, and be built with plain, modern JavaScript. Everything from user authentication, to fetching and finding the user's most-liked posts, to assembling the photos into a downloadable image - will happen in the browser. The end result is a client-side app so simple it can be hosted on Github Pages.

The major tech we'll be using:

  • The <canvas> element
  • ES6 Promises
  • Instagram API
  • Instagram OAuth

Note: Some of the code below has been simplified for the sake of this write-up, and the full source is available in the repo.

Let's Get Started

First, let's define the user stories for our app:

  • As a user, I want to view a collage (3x3 grid) of my 9 most-liked Instagram posts from the most recent year.
  • As a user, I want to download my grid so I can share it with others.

Now let's break this down into smaller tasks:

  • The HTML - Setup views for the following app states - login, loading, and grid. The app will be a single page app (SPA) and the HTML will contain 3 views (<section>s) which will be hidden/visible based on app state.
  • A Router - Create a simple router to handle showing/hiding the 3 views based on app state.
  • Authenticate & Authorize - Use the Instagram OAuth process to authenticate the user and authorize our app for access to the user's Instagram posts.
  • Retrieve the Posts - Retrieve the user's Instagram posts for the most recent year, sort the posts by number of likes/comments, and keep the 9 top posts.
  • Assemble the Grid - Assemble the top 9 Instagram posts into a 3x3 grid by rendering the post images into a <canvas> element.
  • Display the Grid - Display the generated grid as an <img> so the user can save/download for sharing.

The HTML

Our single page app has 3 views, which will be hidden/displayed as needed:

  1. Login - displays a login button to direct the user to the Instagram OAuth process and return the user to our app with an access token.
  2. Loading - displays a loading animation.
  3. Grid - displays the user's 'best nine' grid.

Here's the basic HTML structure containing our 3 views (the <section> elements), and a <canvas> element we'll use to assemble the grid. The js-[something] IDs will be used as selectors in the JS.

<body>
  <main>
  
    <!-- 1. Login -->
    <section id="login" class="view" hidden>
      <a href="https://api.instagram.com/oauth/authorize/..." id="js-login">
        Login with Instagram
      </a>
    </section>
  
    <!-- 2. Loading -->
    <section id="loading" class="view" hidden>
      <div id="js-message">Loading...</div>
    </section>
  
    <!-- 3. Grid -->
    <section id="grid" class="view" hidden>
      <a id="js-download" href="#">
        <img id="js-collage" src="">
      </a>
    </section>

  </main>
  
  <!-- Canvas -->
  <canvas id="js-canvas"></canvas>

  <!-- JS Script -->
  <script type="text/javascript" src="app.js" async></script>
</body>

Notice the hidden attributes on each section.view. This means all views are hidden by default and we'll use JavaScript to control their visibility. Onto the JavaScript!

The JavaScript

Remember our goal is to use only plain, modern JavaScript in this app. We won't be importing any packages or running the JavaScript through a transpiler or bundler. The down-side to this is our app won't work in some older browsers. The up-side is our app is extremely simple.

First, let's setup a simple router to control the visibility of each section - we'll do this by creating a renderView function which accepts 2 parameters:

  • view - The id of the view to display
  • callback - A function to call after the view has been displayed

We also have a pair of utility functions, hideElement and showElement, which add or remove the hidden attribute on an element to make them in/visible.

const renderView = (view, callback) => {
  // Hide all the views
  Array.from(document.querySelectorAll('.view')).forEach(el => hideElement(el));
  // Display the active view
  showElement(document.getElementById(view));

  if (callback) {
    callback();
  }
};

const hideElement = (view) => {
  view.setAttribute('hidden', 'hidden');
}

const showElement = (view) => {
  view.removeAttribute('hidden');
}

Great, we can now hide and show the 3 views of our app. When the app loads we want the the Login view to be visible, so we'll attach a callback to the window.onload event to render the login view.

window.onload = () => {
  renderView('login');
};

At this point our app will display the Login view.

Authenticate the User

The next step is to retrieve the user's Instagram posts. We'll go the preferred route on this and fetch the user's posts using the Instagram API (instead of scraping from Instagram). This requires us to authenticate the user and authorize our app, which involves 2 steps:

  1. Setup an application in Instagram/Facebook - Note: this application uses the deprecated Instagram client registration, but the new Instagram Basic Display API from Facebook works similarly.
  2. Use Instagram's OAuth 2.0 protocol for authentication and authorization.

I'm going to skip the details of setting up the app in Instagram/Facebook. The basic gist is to register a new app through the Instagram/Facebook developer portal, request the required permissions for the app (the most basic permissions allow us to access the user's posts), and define the valid URLs for the website/app. Once the app is setup we can authenticate the user and authorize our app to retrieve the user's Instagram posts.

For the user authentication and authorization we'll use Instagram's OAuth 2.0 protocol. It works like this:

  1. Direct the user to the Instagram authorization URL - If the user is not logged in, they will be asked to log in, then the user will be asked if they would like to grant our application access to their Instagram data.
  2. Once the user has authenticated and authorized our application, Instagram redirects them back to the redirect_uri we defined in the setup of our application on Instagram/Facebook, along with an access_token  URL param. This access_token will be used in subsequent requests to the Instagram API to request the user's data.

To summarize:

  1. The user clicks the login link to direct them to the Instagram authorization URL.
  2. The user is asked to login to their Instagram account.
  3. The user is asked to grant our application access (basic, read-only) to their data.
  4. The user is redirected back to our app along, with a URL containing an access_token we can use to request info from the user's Instagram account.

If the user fails to login, or does not grant our application permission to access their data, an error is returned and our app should handle that. I've removed error handling from this write-up, but the full version

At this point we need to make a small change to our window.onload callback - to detect if the URL contains the precious access_token we need. We'll update the window.onload from above with the following:

window.onload = () => {
  const hash = window.location.hash.substr(1).split('=')
  if (hash[0] === 'access_token') {
    renderView('loading', callbackPics);
    history.replaceState('', document.title, [DOMAIN]);
    return true;
  }

  return renderView('home');
};

The new addition to window.onload looks for an access_token URL param, and if present we render the Loading view and strip the access_token from the URL using history.replaceState().

At this point we have an access_token we can use to make requests on the user's behalf.

Fetch the Instagram Posts

We now have everything we need to request the user's photos. You may have noticed the callbackPics function I passed to renderView() above. When we told the app to display the Loading view we also kicked off a function to retrieve the user's Instagram posts. The callback function looks like this:

const callbackPics = () => {
  fetchMedia()
    .then(createCollages)
    .then(displayCollages)
    .catch(displayError);
};

This Promise chain calls fetchMedia, then createCollages, then displayCollages, or displayError if any of the above throw an error.We'll break each of these down, starting with fetchMedia.

In short, this function calls a recursively fetches the user's Instagram posts until it reaches posts older than year we're interested in. For example, if we're creating a Best Nine of 2019, the function recursively calls getPostsFromYear until it reaches posts from 2018 and stops.

const fetchMedia = () => {
  return new Promise((resolve, reject) => {
    getPostsFromYear(API_ENDPOINT, YEAR).then(response => resolve(response));
  });
};

const getPostsFromYear = (endpoint, year, media = []) => {
  return fetch(endpoint)
    .then(response => response.json())
    .then(({data, pagination}) => {
      const lastMediaYear = getMediaYear(data[data.length - 1].created_time);
      const moreResults = pagination.next_url && lastMediaYear > year - 1;
      const newMedia = data.filter(media => getMediaYear(media.created_time) === year);

      const updatedMedia = media
        .concat(newMedia)
        .sort((a, b) => b.likes.count - a.likes.count || b.comments.count - a.comments.count)
        .splice(0, 9);

      if (moreResults) {
        return getPostsFromYear(pagination.next_url, year, updatedMedia);
      }

      return updatedMedia;
    })
    .catch(displayError);
};

const getMediaYear = date => new Date(date * 1000).getFullYear();

Each subsequent request adds the new post objects to the existing array of post objects, sorts the array of posts by the number of likes/comments to find the most popular posts, and splices the array to remove the extraneous posts (we only need the top 9).

When the function completes, we're left with an array of 9 Instagram post objects - the user's 'Best Nine'.

"Find freedom on this canvas." [Bob Ross]

Now that we have the user's top 9 Instagram posts we need to turn them into a single asset showing the photos in a grid. The <canvas> element is a natural choice here - allowing us to manipulate and layout the user's photos in a grid.

This is where the next part of the Promise chain above comes into play - .then(createCollages). In this step we'll configure our <canvas> element, calculate the position of each image, and draw each image onto the <canvas>.

const createCollages = (media) => {
  const imagePromises = [];

  const gutterWidth = 2;
  const canvas = document.getElementById('js-canvas');
  const context = canvas.getContext('2d');
  const gridNum = 3;
  const imageWidth = Math.floor(750 / gridNum);
  const canvasWidth = (imageWidth * gridNum) + ((gridNum - 1) * gutterWidth);

  canvas.width = canvasWidth;
  canvas.height = canvas.width;
  context.fillStyle = '#ffffff';
  context.fillRect(0, 0, canvas.width, canvas.height);

  media.forEach((item) => {
    const col = i % gridNum;
    const row = Math.floor(i / gridNum);
    const posX =(imageWidth * col) + (gutterWidth * col);
    const posY = (imageWidth * row) + (gutterWidth * row);
    imagePromises.push(addMedia(context, item.images.standard_resolution.url, posX, posY, imageWidth));
  }
  
  return new Promise((resolve, reject) => {
    Promise.all(imagePromises).then(responses => {
      resolve(true)
    })
  })
}

const addMedia = (ctx, url, posX, posY, w) => {
  return new Promise((resolve, reject) => {
    const image = new Image();
    image.crossOrigin = 'anonymous';
    image.onload = () => {
      const crop = Math.min(image.width, image.height);
      ctx.drawImage(image, image.width / 2 - crop / 2, image.height / 2 - crop / 2, crop, crop, posX, posY, w, w);
      return resolve(image);
    };
    image.src = url;
  })
};

There's quite a bit going on here, so let's break it down:

  • Set/calculate some constants - the gutter width (space between images), the size of each image, and the canvas size.
  • Setup the canvas - define the width, height, and fill color of the canvas.
  • Prep the photo objects - for each of the 9 photos calculate the position of the photo in the grid - the X/Y position of each photo on the canvas. The photos will be laid out left to right, top to bottom by number of likes/comments.
  • Load and add the photo - load the Instagram image, crop the photo (we need square images), and draw the photo onto the canvas.

This last step of requesting and adding the photo to the canvas is handled by the addMedia function. You may have noticed we push each addMedia to an array. This is because we need to wait for all 9 photos to load. To do this the addMedia function returns a Promise, which is pushed to an array, imagePromises. Each addMedia Promise in imagePromises is resolved when the photo is loaded, and Promise.all(imagePromises) tells us when all 9 photos have loaded. At this point our canvas is completed and we can move to the final step.

Display the Best Nine

We're finally at the point we can view our Best Nine - the photos have been loaded into the <canvas> element and it's ready for viewing. But we want to do more than just view our Best Nine, we want to create a downloadable image the user can share. Our <canvas> element alone won't allow the user to download the file, so the final step is to turn the canvas into an image, which is responsibility of the last step in the Promise chain above - displayCollages.

const displayCollages = () => {
  const canvas = document.getElementById('js-canvas');
  const downloadLink = document.getElementById('js-download');
  const collageImg = document.getElementById('js-collage');
  
  canvas.dataset['url'] = canvas.toDataURL('image/jpeg', 0.8);
  collageImg.src = canvas.dataset.url;
  downloadLink.href = canvas.dataset.url;
  renderView('grid');
}

The most important part of this final step is the canvas.toDataURL, which returns a data URI containing a string representation of the image in image/jpeg format. In other words, our <canvas> is turned into an inline asset. We can use this inlined asset as the src of our image element, and the href of a download link, so the user to view and download the generated photo grid. Now that the image is ready the last step is to switch to the Grid view using renderView('grid').

The end result is an <img > displaying the <canvas> we assembled from the user's best nine Instagram posts. The image can be clicked to download, or opened in a new browser tab for saving.

The Final Product

The app I built and deployed is slightly more fully-featured than outlined in this article - it contains error handling, and allows the user to create a grid of their top 4, 9, 16, or 25 Instagram posts. Even with the added functionality the app is only 6k of uncompressed, readable JS, and an HTML file.

The app can be viewed at TopXofY.com and the source code is on Github.

Comments