Build a Splash Page with Next.js using Airtable as a CMS.

Build a Splash Page with Next.js using Airtable as a CMS.

·

28 min read

Introduction

Let's build a fully optimized landing page using Airtable and Next.js. Over the course of this 3-part series, you'll work your way up to building a highly scalable landing page with an easy-to-use custom Airtable CMS.

In this first post, we'll begin, with a general overview of Airtable and Next.js and why the combination makes an ideal solution for several real-world applications. You'll also learn how to configure an Airtable "base" and integrate it with Next.js using static site generation (SSG).

In the rest of the series, you'll see how you can simplify the solution and scale using Sync Inc. Which provides a synchronized cloud-based Postgres instance so you can run raw SQL queries against your Airtable data.

What We're Building

In this first post, we'll build a simple yet versatile Splash page. Splash pages used to be more common in the past, but we still see remnants of them in modern applications. Splash pages generally are full-screen pages that are typically used for a single purpose, the most common example would be a 404 page.

The Brief

In our case, we're building a splash page for an up-and-coming Saas startup that provides uptime monitoring services. Our goal is to create an attractive "call-to-action" splash page to act as a placeholder while the full site and app are being built. This will help ensure the company can capture any early interest in their product and creates a list of followers who are enthusiastic about their upcoming launch.

By the end, you'll have a working, fully customizable splash page...

  • built using Airtable as a CMS with markdown support.
  • statically generated by Next.js
  • with built-in SEO support and image optimization.

Later in the series, you'll learn how you can:

  • simplify the integration with Sync Inc.
  • scale using a fully provisioned cloud Postgres database.
  • leverage the power and simplicity of raw SQL queries.

006.png

Visit Splash Page Demo

What is Airtable?

Airtable is an online platform for managing and sharing relational data in an easy-to-use collaborative workspace. On the surface, Airtable looks like a fun spreadsheet but functions like a robust relational database and provides various tools, integrations, and automations that make it a very effective solution for most use-cases.

Why Airtable?

  • Generous Free Tier
  • Powerful Automations

What is Next.js?

Next.js is a highly versatile, production-grade React framework. It provides thoughtful optimizations that just work such as a built-in CDN, server-less functions, and link prefetching. To take it one step further Next.js allows the developer to choose between static and server rendering on a per-page page basis.

Why Next.js?

  • Super-fast performant websites and applications
  • Optimized for production from the start
  • Developer's choice: Static or Server Rendering
  • Typescript and CSS Support

Prerequisites

  • Basic understanding of Javascript(ES6) and React.
  • General understanding of relational databases, SQL statements and joins.
  • System Requirements
    • Node.js 10.13 or later
    • Either macOS, Windows (including WSL), or Linux

Resources

  • Airtable Base - This is the Airtable database you'll be working from.
  • Starter Repo - You can use this repo to clone our starter and follow along.
  • Finished Branch - Use this repo to compare your work if you run into any issues.
  • CodeSandbox Starter - You can also follow along using this CodeSandbox starter project.
  • Project Demo - This is a working demo of the Splash page project from this post.

Airtable Setup

First, let's get an Airtable base set up.

  1. Create an airtable account or sign into your airtable account.
  2. Then click here to Clone the Airtable base you'll be using for this project.

a001.png

Airtable Credentials

Once you've cloned your base, you'll need both your Airtable API key and base-id to configure your application.

NOTE: The API key should be treated as a secret and should be kept confidential. The base-id is semi-private, but for this project, we'll be treating it as a secret as well.

Generate API key

Let's start by generating your Airtable API key.

  1. Click on your user icon in the top-right-hand corner.

a002.png

  1. From the drop-down menu select the "Account" option.

a003.png

  1. Under "Account Overview", click the button labeled "Generate API key".

a004.png

⚠️ Keep this API key secure, you'll need it when you configure your Next.js application.

Locate Base-Id

You can find your base-id from Airtable's live documentation.

  1. From your base, click the "Help" button on the top-right of your screen.

a005.png

  1. From the sidebar menu, click the link labeled "API documentation".

a004-2426429.png

  1. You'll find your base-id listed right under the second paragraph in green:

a007-1.png

☝️ Keep your base-id secure but available you'll need it to configure your Airtable base with your Next.js application.

Once you have your Airtable credentials, let's move on to setting up your Next.js application.

Structuring Airtable as a CMS

If this is your first time using Airtable then this section will get you up to speed on some of the terminology and concepts you'll need to proceed.

The base you cloned is designed to be very flexible so you can always add fields to customize it once you're semi-familiar with how it functions.

Inside your base, you will find several tables listed at the top of the screen, (pages, sections, blocks, media, and seo ). These tables help us arrange our data logically in a way that resembles a spreadsheet, but functions like a relational database.

011.png

As shown above, each table in a base consists of a collection of rows and each row represents an individual record. Records are a collection of fields that hold specific data about the object they represent. More about Airtable structure

Airtable provides you with various field types that can be used to store all kinds of values. For this project we'll only be using a few of the more common field types:

  • Single line text - used to store small strings of text.
  • Number - can be configured to hold integers or floating points.
  • Long text - used for longer text content (i.e., paragraphs).
  • Single select - select a single option from a predefined list.
  • Multiple select - select multiple options from a predefined list.
  • Attachment - used to store files.
  • Linked Records - used to link a record from one table with another.

NOTE: Even though all of this may seem like a lot, we're actually just scratching the surface here with Airtable. Luckily they have some incredible documentation both for the platform and for the API.

And if you really want to dive deep, I came across this article that covers just about everything you can do with Airtable as a developer.

Tables

We use Airtable's field types in our base to help organize our data, based on the type of values we want to accept for any given field. Let's take a deeper look at each field and the type of data it holds.

pages
fieldfield typedescription
titlesingle line textrepresents the title for the current page and can be used to populate nav menus as needed.
pathsingle line textused to populate the href value when linking to a page.
ordernumberused to control the order in which each page should be displayed (also useful for nav menus).
sectionIdlinked recordused to link to related sections for each page.
seoIdlinked recordused to link to SEO metadata for each page.
sections
fieldfield typedescription
titlesingle line textrepresents the title for the section (only used for reference purposes).
ordernumberused to control the order of each section (on a per-page basis).
typesingle selectused to control which component the application will use to render each section.
filtermultiple selectused as a boolean modifier that is useful for conditionally rendering components or layouts.
blockIdlinked recordused to link to related blocks for each section.
pageIdlinked recordused to link to the page that will render this section.
blocks
fieldfield typedescription
titlesingle line texttitle for each block, used as the heading element's content. (i.e., h1, h2, h3...)
leadsingle line textshort text block that will be rendered above the heading tag in our application.
excerptsingle line textshort text block that will be rendered below the heading tag in our application.
contentlong textlong from content (i.e., paragraph) that will be rendered as a block of text.
mediaIdlinked recordused to link to related media for each block.
sectionIdlinked recordused to link to the section that will render this block.
media
fieldfield typedescription
titlesingle line textrepresents the title for each media item (only used for reference purposes).
urlattachmentholds a file attachement as an object from which we use the url property.
altsingle line textused to populate the alt tag for each image.
blockIdlinked recordused to link to the block that will render each media item.
seoIdlinked recordcan be used to reference an image from the seo table to be used as the og:image for each page.
seo
fieldfield typedescription
titlesingle line textused to populate the title tag for each page.
descriptionsingle line textused to populate the description tag.
keywordssingle line textused to populate the keywords tag.
sitenamelinked recordused to populate the site_name tag.
urllinked recordused to populate the site_url tag.
pageIdlinked recordused to reference the page that each SEO record belongs to.
mediaIdlinked recordused to link to related media to be used as the og:image for each page.

Relationships

Since one of the pre-requisites of this post was an understanding of relational databases, we won't dive too deep into the following concept of relationships.

001.png

In the diagram above you can see each table along with each of its fields and corresponding field types. You can also get a rough idea about how some of the fields are used to link data from different tables together.

At its core Airtable is a relational database. The platform abstracts away a lot of the relational complexity and provides an easy-to-use interface that resembles a spreadsheet instead. Under the hood, relational concepts such as the use of primary and foreign keys are implemented to link records of your Airtable base to one another across your different tables.

Airtable calls these types of fields linked records, but these types of records behave similarly to the way foreign_keys are implemented in relational databases.

If you're not familiar with these relational database terms, I advise taking a quick second to skim through the introduction of this article. And also more about linked records in Airtable.

In Airtable the first field in each table acts similar to a primary_key. Airtable calls these fields the primary field. In your Airtable base, the first field on each table is the title field which essentially functions as the primary reference to the record it represents.

The contents of each primary field field get indexed by Airtable which means we can search for our records by their title.

☝️ NOTE: It's good practice to make sure the field that is used as the primary_key for each table has a unique value, although Airtable does not enforce this constraint. More about: Airtable primary keys

Naming Convention

We have adopted a simple naming convention (from relational databases) for the naming of each of the foreign_key fields where the field is named as a combination foreign_tablename + "Id".

For example, on the sections table, the pageId field links to a page record so it's aptly named pageId.

This naming convention helps us address a disparity between how the Airtable UI presents data for linked records as a list of titles in our Airtable base versus how the API represents this same data as an array of unique ids.

Airtable API Relationships

Let's dig a bit further at the difference between how data looks in the Airtable UI versus in the Airtable API using a small chunk of the data in your new Airtable base.

The API response snippet shown below for the sectionId field returns an array of ids. Each id in the array represents a linked record.

Airtable response for the sectionId field:

"sectionId": [ "recTiTyzYFdayHJs5", "recm1iacYCQPuIseY", "recnJxlc90m0VYWeu" ]

But in the Airtable UI, these same linked records appear as the sectionId: 002.jpg

Ultimately, this behavior in the API makes sense. Matching and looking up IDs is better than working with ever changing strings.

Relationships can sometimes be tricky to reason about, but we're not doing anything complex here. As long as you have a general understanding of the basics you should feel confident enough to proceed.

Next.js Setup

Starter overview

The starter repo is a boilerplate create-next-app with a single dependency added: airtable.js. All the styles needed for the entire project are included with the starter as CSS modules all located in the /styles directory.

There are a couple of pre-fabricated presentational components and a few helper methods also included with the starter repo. We'll go over each in a bit of detail, but first, let's set up the application.

Clone the starter repo locally.

$ git clone https://github.com/gaurangrshah/next-airtable-splash

Add credentials

Now add your Airtable credentials as environment variables to your local repository. With Next.js you can simply create a file called .env.local at the root of your project. Next.js adds this file to your .gitignore by default and securely injects the variables into your environment at build time. More about: Next.js Environment Variables

# .env.local

AIRTABLE_API_KEY={{YOUR_AIRTABLE_API_KEY_HERE}}
AIRTABLE_BASE_ID={{YOUR_AIRTABLE_BASE_ID_HERE}}

Now that you have your application credentials configured go ahead and start your development server to make sure everything is working for you:

$ yarn dev

Now navigate to http://localhost:3000/ . You should see the welcome message shown below.

007.png

And there you have it. Your Next.js application is now running locally and you've got your environment variables configured.

The next thing you'll want to do is configure your Airtable base to work with your Next.js application, but before we get there, let's take a moment to go over the structure of your Airtable base and how it will work as a CMS.

Integrate Airtable

Now, connect your Airtable base to your Next.js app. You already have airtable.js included as a dependency in the package.json file of your Next.js application.

008.png

So we can jump right in and begin by instantiating an instance of Airtable in our project.

// lib/airtable.js

const Airtable = require("airtable");
const { getMinifiedRecords, minifyRecord } = require("../utils/airtable");

// airtable queries are based on "views"
export const VIEW = "Grid view";

// instantiate airtable
export const table = new Airtable({
  apiKey: process.env.AIRTABLE_API_KEY,
});

// create reference to airtable base
export const base = table.base(process.env.AIRTABLE_BASE_ID);

Even though airtable.js provides a very easy-to-use wrapper around the Airtable REST API, working with Airtable data on the client-side can be a bit of a clunky process. To make querying the API easier, your starter includes several helper functions. These functions are used to help you interact with the API and make data handling a bit more manageable:

// lib/airtable.js

// Airtable query helpers
export const getPage = async (
  page = "launch",
  table = "pages",
  options = { view: VIEW }
) => {
  // creates a filter allowing your to query your records by searching for matching title values.
  const filterByFormula = `AND(title='${page}')`;

  const records = await base(table)
    .select({ filterByFormula, ...options })
    .all();
  return getMinifiedRecords(records);
};

export const getRecordById = async (table, id) => {
  const record = await base(table).find(id);
  return minifyRecord(record);
};

export const getRelatedRecords = async (table, ids = []) => {
  const records = await ids.map(async (id) => {
    const record = await getRecordById(table, id);
    return record;
  });
  return Promise.all(records);
};

getPage() is a helper function that hard-codes some default logic that you'll need for your queries (i.e, table, page) and provides the ability to pass in any additional options. This allows you to keep your logic extensible. We also make use of the filterByFormula option to target and select the data we need to render the launch page.


getRecordById() is used to query a single record by its id.


getRelatedRecords()is a function that takes in an array of ids. The function calls getRecordById() for each id in the array and returns the results as a single array of records.

Notice the helpers we defined reference two other utility functions getMinifiedRecords() and minifyRecord. These functions do not interact with the Airtable API themselves but are instead used to transform your Airtable records into data that is more manageable on the client-side.

// utils/airtable.js

// maps over the records, calling minifyRecord, giving us required data
export const getMinifiedRecords = (records) => {
  if (!Array.isArray(records)) return minifyRecord(records);
  return records.map((record) => minifyRecord(record));
};

// gets the data we want and puts it into variables we can reference
export const minifyRecord = (record) => {
  return {
    id: record?.id,
    fields: record?.fields,
  };
};

minifyRecord() takes a record from an Airtable response and only returns the data we need for our application. It strips away any helper methods and utility functions that are generally included on the response object by airtable.js.


getMinifiedRecords() takes an array of Airtable response records and calls minifyRecord() for each one before returning the results.

With Airtable instantiated and your helper functions in place, you can turn your attention to using these helpers to fetch your Airtable data and use it to render static a page.

Next.js Data-Fetching

Next.js supports several data fetching strategies out of the box. In this post, we'll be focusing on getStaticProps() which is how we create optimized static pages in Next.js.

Static Site Generation (SSG)

003.jpg

getStaticProps() provides a way for you to query the file system or any external resources during the build process. This means its logic can be executed on the server at build time in your production environment and on each page load in your local development environment.getStaticProps() is an asynchronous function that can be exported from any top-level page component. Next.js will automatically execute this function at build time before generating the static page. More about: getStaticProps()

⚠️ In development, you can always trigger a re-fetch with getStaticProps() by refreshing the page, but in production, you'll have to redeploy the application either manually from your hosting console or with a deploy hook.

☝️ NOTE: Next.js offers two additional data fetching strategies, incremental static regeneration (ISR), and traditional server-side rendering (SSR). Any of these features including static site generation (SSG) can be implemented on a per-page basis. This allows us as the developers to choose which strategy is right for each use case.

For more about each strategy, I highly recommend this article.

Now that you have an idea of what getStaticProps() can do, let's put it to work and use it to query data from your Airtable base.

// pages/index.js

import Head from "next/head";
import styles from "../styles/Home.module.css";

export async function getStaticProps() {
  // asynchronously import neccessary modules so we can avoid any client side compilation
  const { getPage, getRecordById, getRelatedRecords } = await import(
    "../lib/airtable"
  );

  // query inital data from airtable
  const [launch] = await getPage("launch");

  // get related seo metadata
  if (launch?.fields?.seoId?.length) {
    const seo = await getRelatedRecords("seo", launch?.fields?.seoId);

    if (seo[0]?.fields?.mediaId.length) {
      // get related media for seo og:image
      const ogImage = await getRecordById("media", seo[0]?.fields?.mediaId[0]);
      seo[0].fields.ogImage = ogImage;
    }
    launch.seo = seo;
  }

  return {
    props: {
      page: launch?.id ? launch : [],
    },
  };
}

export default function Home({ page }) {
  return (
    <div className={styles.container}>
      <Head>
        <title>Create Next App</title>
        <link rel='icon' href='/favicon.ico' />
      </Head>

      <main className={styles.main}>
        <h1 className={styles.title}>
          Welcome to <a href='https://nextjs.org'>Next.js!</a>
        </h1>
        <div className={styles.card}>{JSON.stringify(page, null, 2)}</div>
      </main>
    </div>
  );
}

As you can see, we're only fetching a subset of our data and dumping it directly onto the page just to make sure everything is wired up properly. You should see your data dumped to the page as shown below.

009.png

It may not seem like it, but a lot of our logic is being handled by getStaticProps() so let's go over it step by step.

We first import any modules we need asynchronously inside of getStaticProps() this is a neat way to ensure that all of this logic only runs on the server at build time.

// asynchronously import neccessary modules and avoid any client side evaluation
const { getPage, getRecordById, getRelatedRecords } = await import(
  "../lib/airtable"
);

Then we query Airtable for the launch page data using the getPage() helper function.

// query inital data from airtable
const [launch] = await getPage("launch");

Which (amongst the rest of the data) returns an array of ids for the seoId field. In this case, the array contains a single id linking to the seo table and pointing to the record with the title "Uptime Monitoring". Take a look at the image below as a refresher for how pages table is related to the seo table.

014.png

Recall:

that the title field shown above in the UI is represented as an id in the API response. The API response automatically substitutes the title value in linked records with unique ids instead.

We can use the seoId array to help us query Airtable once more for the related SEO record for your launch page.

// get related seo metadata
if (launch?.fields?.seoId?.length) {
  const seo = await getRelatedRecords("seo", launch?.fields?.seoId);

  if (seo[0]?.fields?.mediaId.length) {
    // get related media for seo og:image
    const ogImage = await getRecordById("media", seo[0]?.fields?.mediaId[0]);
    seo[0].fields.ogImage = ogImage;
  }
  launch.seo = seo;
}

We use the seoId array to fetch the related meta data for the launch page from the seo table. We also check if any media image exists so that we can use it as the og:image for the launch page..

Once we have our data we can return the entire page object as a prop from getStaticProps() which contains all of the related records from each of our responses.

return {
  props: {
    page: launch?.id ? launch : [],
  },
};

Lastly, we access the page prop from our component and dump our data onto the page as JSON.

export default function Home({ page }) {
  return (
    <div className={styles.container}>
      <Head>
        <title>Create Next App</title>
        <link rel='icon' href='/favicon.ico' />
      </Head>

      <main className={styles.main}>
        <h1 className={styles.title}>
          Welcome to <a href='https://nextjs.org'>Next.js!</a>
        </h1>
        <div className={styles.card}>{JSON.stringify(page, null, 2)}</div>
      </main>
    </div>
  );
}

Now that you understand how we're able to fetch data from Airtable with Next.js using getStaticProps(), let's take a look at how that data correlates to the structure of your application.

016.png

The diagram below illustrates how each of the fields from your SEO table can be mapped to the corresponding meta tag in your application's HTML markup.

This allows you to make changes in your Airtable base and directly update the structure of your markup resembling the functionality of a CMS.

⚠️ Remember: In development you will see new updates from your Airtable base with each page refresh, but in production, you'll need to rebuild and deploy your application in order to see any changes take effect.

Now that you have an idea of how we'll use the data from your Airtable base as a CMS to render out the initial structure of your application, we can turn our attention back to getting the rest of the related data you'll need to fully build out the Splash page.

We're going to do the same thing we did above to get the related SEO data, but we'll have to make several round trips to the Airtable API in order to get all of the data we need.

First, we'll need to query for all of the sections needed to render the launch page. Then we can iterate over each section to get the related blocks for each section. And while we're iterating, we'll need to do the same for any media image related to each block. We essentially return each responses - all while building up the final page object we'll use to render the application.

All of this logic will be handled within getStaticProps(). Similar to the way we queried for the SEO metadata.

// pages/index.js

export async function getStaticProps() {
  // asynchronously import neccessary modules and avoid any client side evaluation
  const { getPage, getRecordById, getRelatedRecords } = await import(
    "../lib/airtable"
  );

  // query inital data from airtable
  const [launch] = await getPage("launch");

  // get related seo metadata
  if (launch?.fields?.seoId?.length) {
    const seo = await getRelatedRecords("seo", launch?.fields?.seoId);

    if (seo[0]?.fields?.mediaId.length) {
      // get related media for seo og:image
      const ogImage = await getRecordById("media", seo[0]?.fields?.mediaId[0]);
      seo[0].fields.ogImage = ogImage;
    }
    launch.seo = seo;
  }

  // get related data if there is any
  if (launch?.fields?.sectionId?.length) {
    const sections = await getRelatedRecords(
      "sections",
      launch?.fields?.sectionId
    );

    // get all blocks for each section and their related media
    if (sections?.length) {
      launch.sections = await Promise.all(
        await sections.map(async (section) => {
          if (section?.fields?.blockId?.length) {
            const blocks = await getRelatedRecords(
              "blocks",
              section?.fields?.blockId
            );
            if (blocks?.length) {
              section.blocks = await Promise.all(
                await blocks.map(async (block) => {
                  if (block?.fields?.mediaId?.length) {
                    const media = await getRelatedRecords(
                      "media",
                      block?.fields?.mediaId
                    );

                    if (media?.length) {
                      block.media = media;
                    }
                  }
                  return block;
                })
              );
            }
          }
          return section;
        })
      );
    }
  }

  return {
    props: {
      page: launch?.id ? launch : [],
    },
  };
}

☝️ NOTE: that once again we rely on the array of ids we get back from each of the linked record fields to then request each related record by id.

With this in place, you should now be seeing even more data being dumped onto your page as shown in the image below.

010.png

Rendering Data From Airtable

Now that you already have all of your data dumped on the client-side of your application you can style and present it however you want to.

For this project, the starter comes with several pre-fabricated components that you can use to render this data. Let's take a look at how these components fit together to make up the structure of your application UI.

005.jpg

As you can see from the diagram above, our index page renders the Splash component, which in turn is responsible for rendering each of the sections in your Splash page.

Each section from your Airtable base maps to a component in your application. We use the type field to determine which component renders each section. The image below shows an example of the mapping between your Airtable fields and the markup used to render them with the Hero component.

015.png

The first component your Splash component is responsible for rendering is the Hero component.

// components/Hero.js

import styles from "../styles/Hero.module.css";
import Image from "next/image";

export const Hero = ({ data }) => {
  return (
    <div className={styles.hero}>
      <div className={styles.heroContent}>
        <p className={styles.lead}>{data[0]?.fields?.lead}</p>
        <h1 className={styles.heading}>{data[0]?.fields?.title}</h1>
        <p className={styles.excerpt}>{data[0]?.fields?.excerpt}</p>
      </div>
      {data[0]?.media?.length && (
        <div className={styles.heroImage}>
          <Image
            layout='intrinsic'
            src={data[0]?.media[0]?.fields?.url[0]?.url}
            alt={data[0]?.media[0]?.fields?.alt}
            objectFit='fill'
            width={600}
            height={600}
          />
        </div>
      )}
    </div>
  );
};

The Hero component simply renders a 2 column responsive flexbox layout. If you'll look closely at the markup you'll see that the hero component only accepts thelead, heading, and excerpt fields from your blocks table. It is not templated to render a content field. It will also render the related media image as well.

You can take a look at the styles we use to achieve this layout in styles/Hero.module.css.

☝️ REMINDER: All of the styles for each of the components included with the starter can be found in the /styles directory.

Next.js Image Support

Next.js recently introduced some really powerful image optimizations with their built-in Image component. One of the features we are using for the Hero component above is the layout prop with a value of intrinsic which simply tells Next.js to optimize this image with some basic responsiveness. More on Next.js Image Props.

⚠️ NOTE: There is a caveat the Next.js image component works well out of the box with any images saved locally as an asset in your project to the /public directory, otherwise it needs us to define a domain for any external images so that it understands which domains to optimize for.

This is done by extending the default next.js webpack configuration via a next.config.js file. Under the hood, Next.js will execute file this during the build process and optimize your package bundle with responsive variations of your image.

// next.config.js

module.exports = {
  images: {
    domains: ["dl.airtable.com"],
  },
};

With this in place, Next.js will know to optimize images from this domain. You can also list multiple domains if you're using several resources for image handling

⚠️ NOTE: Your local server will need to be restarted after any changes to your next.config.js file.

Next, we have a List component that is used to render out a list of benefits with icons corresponding to each benefit.

// components/List.js

import Image from "next/image";
import styles from "../styles/List.module.css";

export const List = ({ data, ...rest }) => {
  return (
    <ul className={styles.list}>
      {data?.map((block) => (
        <ListItem
          key={block?.id}
          className={styles.listItem}
          img={block?.media[0]?.fields}
          {...rest}
        >
          {block?.fields?.title}
        </ListItem>
      ))}
    </ul>
  );
};

export const ListItem = ({ img, filter = [], children }) => {
  return (
    <li className={styles.listItem}>
      {filter?.includes("withIcons") && (
        <span className={styles.icon}>
          <Image
            layout='intrinsic'
            src={img?.url[0].url}
            alt={img?.alt}
            width='28px'
            height='28px'
          />
        </span>
      )}
      {children}
    </li>
  );
};

You'll also notice that we're rendering a ListItem for each benefit which includes an icon and some text for each. Each benefit is represented as its own block in AIrtable so we have to iterate over them to render each one out.

The next component we'll need is the Cta component which simply renders a standard signup form for updates regarding your launch.

// components/Cta.js

import styles from "../styles/Cta.module.css";

export const Cta = ({ data, filter = [] }) => {
  const handleSubmit = (e) => {
    e.preventDefault();
    const input = e.target.querySelector("input");
    console.log(input.value);
  };

  return (
    <div className={styles.cta}>
      {data?.map((block) => (
        <div key={block?.id}>
          <h2>{block?.fields?.title}</h2>
          <p>{block?.fields?.excerpt}</p>
          {filter.includes("withForm") && (
            <form onSubmit={handleSubmit}>
              <div className={styles.inputGroup}>
                <input
                  type='email'
                  placeholder='you@youremail.com'
                  required
                  className={styles.input}
                />
                <div className={styles.inputRight}>
                  <button type='submit' className={styles.submit}>
                    Sign up now
                  </button>
                </div>
              </div>
            </form>
          )}
        </div>
      ))}
    </div>
  );
};

Although you have a bit more logic in this component, it's still quite simple.

The component uses the filter field as a modifier to conditionally render a form. This helps us keep the Cta component extendable and reusable with several variations that can be applied from your Airtable response.

For our use case the form simply prints the data out to the console, but you could just as easily send the data to a mailing list provider or other external resources.

The last component we'll need is the Splash component. This component iterates over the page's sections array and renders the appropriate section with the corresponding component.

// components/Splash.js

import { Container, Wrapper, Row } from "./Containers";
import { Hero } from "./Hero";
import { List } from "./List";
import { Cta } from "./Cta";

const components = {
  list: List,
  cta: Cta,
};

export const Splash = ({ data }) => {
  if (!Array.isArray(data)) return null;
  const [hero, ...restComponents] = data;

  return (
    <Wrapper>
      <Container>
        {hero && (
          <Hero
            key={hero?.id}
            data={hero.blocks}
            filter={hero?.fields?.filter}
          />
        )}
        <Row>
          {restComponents.map((section) => {
            const Component = components[section?.fields?.type];

            if (section?.fields?.type.includes("list")) {
              return (
                <Component
                  key={`${section?.id}-${section?.order}`}
                  data={section?.blocks}
                  filter={section?.fields?.filter}
                />
              );
            }
            if (section?.fields?.type.includes("cta")) {
              return (
                <Component
                  key={`${section?.id}-${section?.order}`}
                  data={section?.blocks}
                  filter={section?.fields?.filter}
                />
              );
            }
            return null;
          })}
        </Row>
      </Container>
    </Wrapper>
  );
};

The Splash component gives us some control over which components are used to render each section of our Airtable data. We use the type field to define the component that is used to render this section and the filter field to further add granular control by allowing conditional rendering logic.

There's one final thing left to do. You'll need to render the entire Splash component from pages/index.js.

// pages/index.js

import Head from "next/head";

import { Splash } from "../components/Splash";
import styles from "../styles/Home.module.css";

export default function Home({ page }) {
  console.log();
  return (
    <div className={styles.pageWrapper}>
      <Head>
        <title>{page?.seo[0]?.fields?.title}</title>
        <meta name='viewport' content='initial-scale=1.0, width=device-width' />
        <meta name='description' content={page?.seo[0]?.fields?.description} />
        <meta property='og:title' content={page?.seo[0]?.fields?.title} />
        <meta
          property='og:site_name'
          content={page?.seo[0]?.fields?.sitename}
        />
        <meta property='og:url' content={page?.seo[0]?.fields?.url} />
        <meta
          property='og:description'
          content={page?.seo[0]?.fields?.description}
        />
        <meta
          property='og:image'
          content={page?.seo[0]?.fields?.ogImage?.fields?.url[0]?.url}
        />
        <link rel='icon' href='/favicon.ico' />
        <link rel='cannonical' href={page?.seo[0]?.fields?.url} />
      </Head>

      <main className={styles.main}>
        <Splash data={page?.sections} />
      </main>
    </div>
  );
}

All you have to do pass down your Airtable sections array into the Splash component as props and it handles the rest.

Also Note: We've also implemented search engine optimization using data from our Airtable seo table. This is just a basic implementation and if you're looking for a more robust solution I highly recommend using next-seo for projects that need a more extensible SEO implementation.

Summary

That's it for the first part of the series. I imagine this was a lot to take in, but let's take a quick look at how far we've come in just the first part of our series.

So far you've learned how to implement a custom CMS for your Splash page using Airtable. You also got a close look at how Next.js gives you the ability to perform server-side operations even with static pages at build time making it a great choice for this use case. And we also covered how you can use Next.js for some really simple image optimization.

Next Steps

You've already accomplished a lot already, but I would advise opening up your Airtable base, and from the blocks table start to edit some of your content. You should be able to see your changes reflected in your application after each page load just like you would with any CMS.

Next I'd suggest playing around with the application, and and adding a new section to it. Something like a "founder's message" seems like a good fit.

In the next part of this series, we'll go even further, and take a look at how we can use Sync Inc to synchronize our Airtable base with a cloud Postgres instance. This not only gives your application enterprise-grade scalability but also opens your application up to tons of possibilities including the ability to query your Airtable data with the power and flexibility of raw SQL queries.

So join me for the next installment and be sure to follow me on twitter: @soham_asmi to stay up to date with my latest posts and reach out to me with any feedback or questions.

Until next the next one... 👋


Hey everyone, I'm Gaurang, a full-stack developer that is constantly learning and building in public. I love that I was able to change my career at such a late stage in life all by learning online with so many different resources. I generate content to reinforce what I'm learning and to help others like me in the future.

If you enjoyed my content, please feel free to connect with me wherever you spend your time online.