39
loading...
This website collects cookies to deliver better user experience
bases
, tables
, records
, fields
. We discussed the role of foreign
and private
key relationships and how they're implemented under the hood in Airtable. ⚠️ NOTE: If you're just stumbling upon this article, and much of this sounds unfamiliar to you I suggest starting at the beginning with the first article, as we cover many of these concepts in much greater detail. In either case, feel free to comment below with any questions or issues you may have!
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.
Project Demo - This is a working demo of the Splash page project for this project.
CodeSandbox Starter - You can also follow along using this CodeSandbox starter project.
☝️ In order to facilitate the new requirements for building out a landing page, we've made some changes to the underlying Airtable base.
Create an Airtable account or sign in to your Airtable account.
Then click here to Clone the Airtable base you'll be using for this project.
API key
and base-id
to configure your application.⚠️ NOTE: Your 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.
☝️ If you're unsure of how to get your credentials, check out the "Airtable Credentials" section from the first article.
☝️The biggest change you'll notice is the addition of a new links
table to help manage all of the links on our landing page.
Also notice that there are two new fields on the blocks
table. The first, named order
, is used to primarily ensure a stringent sequence when rendering several blocks
within a single section as we'll soon be doing for all of our unordered bulleted lists.
bullets
which is implemented as rich-text
(Rich-Text is a sub-option available to any long-text
type field). rich-text
as markdown under the hood, because this allows us to use this field type to render more complex nested content. In our case, we'll use the bullets
rich-text
field to render unordered bulleted lists. For more about rich-text
fields in Airtable see: Enabling rich-text
🚧 If any of the information or terminology presented above seems ambiguous to you at all then I highly recommend taking some time to review the first two articles in this series, where we discussed Airtable concepts such as tables and types in much more detail.
⚠️ If not take a look at the "SyncInc Setup" section of that article for a step-by-step guide to setting up SyncInc from scratch.
Or you can find detailed instructions in the SyncInc Docs: Connect to your Postgres Database
post3-starter
branch.git clone -b post3-starter https://github.com/gaurangrshah/next-airtable-splash.git
Once again we're cloning a specific branch so that you can take advantage of some pre-built components and default styling already applied for you.
Hopefully, this allows us to maintain focus on the core goals of this project.
cd next-airtable-splash
yarn
yarn dev
In this article, we'll be building out a new page route /landing
which will render the data from our landing page.
.
├── README.md
├── components
│ ├── Containers.js
│ ├── Link.js
│ ├── MarkdownJsx.js
│ ├── SEO.js
│ ├── landing
│ │ ├── BenefitsCards.js
│ │ ├── BrandList.js
│ │ ├── Card.js
│ │ ├── FeaturedBenefit.js
│ │ ├── FooterCta.js
│ │ ├── Header.js
│ │ ├── Hero.js
│ │ ├── Pricing.js
│ │ ├── RiskCta.js
│ │ ├── SectionHeading.js
│ │ ├── Testimonial.js
│ │ └── index.js
│ └── splash
│ ├── Cta.js
│ ├── Hero.js
│ ├── List.js
│ └── index.js
├── lib
├── next.config.js
├── package.json
├── pages
│ ├── _app.js
│ ├── api
│ │ └── hello.js
│ ├── index.js
├── public
│ ├── check.png
│ ├── favicon.ico
│ ├── logo-holder.svg
│ └── vercel.svg
├── styles
│ ├── Containers.module.css
│ ├── Home.module.css
│ ├── Landing.module.css
│ ├── Markdown.module.css
│ ├── globals.css
│ ├── landing
│ │ ├── BrandList.module.css
│ │ ├── Card.module.css
│ │ ├── FeaturedBenefits.module.css
│ │ ├── FooterCta.module.css
│ │ ├── Header.module.css
│ │ ├── Hero.module.css
│ │ ├── Pricing.module.css
│ │ ├── RiskCta.module.css
│ │ ├── SectionHeading.module.css
│ │ └── Testimonial.module.css
│ ├── normalize.css
│ └── splash
│ ├── Cta.module.css
│ ├── Hero.module.css
│ └── List.module.css
├── utils
│ └── data-helpers.js
└── yarn.lock
Just like in the previous articles, you'll find that the starter also includes all of the styles and each of the components you'll need for this project including a few helper functions that will make handling the data a bit simpler.
.env.sample
file at the root of your project, simply rename this to .env.local
, and fill in the values from your SyncInc dashboard.# .env.local
DB_USER=
DB_HOST=
DB_NAME=
DB_PW=
DB_PORT=
SyncInc
dashboard click the "Connect" button.SyncInc
dashboard/lib/pg-pool.js
file:// /lib/pg-pool.js
const { Pool } = require("pg");
// @link: https://node-postgres.com/guides/project-structure
// @link: https://node-postgres.com/guides/async-express
const pool = new Pool({
// configure database (* optional)
host: process.env.DB_HOST,
port: process.env.DB_PORT,
user: process.env.DB_USER,
password: process.env.DB_PW,
database: process.env.DB_NAME,
});
// retains count for number of connections established
let count = 0;
// used to log each pool connection
pool.on("connect", (client) => {
client.count = count++;
console.log(`clients connected: ${count}`);
});
// export an async query, which takes in necc. args.
module.exports = {
// query from pooled connection
async query(text, params) {
const start = Date.now(); // get starting time
// connect to pool
const client = await pool.connect();
const res = await client.query(text, params);
client.release();
const duration = Date.now() - start; // get elapsed time
// log results
console.log("executed query", {
/* text, params, */
duration,
rows: res.rowCount,
});
return res; // return results
},
};
This has already been implemented for you in order to ensure that the splash page we built in the last project still remains functional.
client
directly to query for our data and that works well for a single query. But for our use case, we'll need to make a separate query for each static page we want to generate at build-time in our application. The client
will normally throw an error because essentially we're trying to re-use the same client for different queries.For more information see: When to use a Pool vs a Client
// configures postgres pool
const pool = new Pool({
// configure database (* optional)
host: process.env.DB_HOST,
port: process.env.DB_PORT,
user: process.env.DB_USER,
password: process.env.DB_PW,
database: process.env.DB_NAME,
});
// used to log each pool connection on each `connect` event
pool.on("connect", (client) => {
client.count = count++;
console.log(`clients connected: ${count}`);
});
query
function which we use as a wrapper to connect to a new client from our pool.// export an async query, which takes in necc. args.
module.exports = {
// query from pooled connection
async query(text, params) {
const start = Date.now(); // get starting time
// connect to pool and derive client
const client = await pool.connect();
const res = await client.query(text, params);
client.release();
const duration = Date.now() - start; // get elapsed time
// log results
console.log("executed query", {
/* text, params, */
duration,
rows: res.rowCount,
});
return res; // return results
},
};
This function also takes in two arguments, text
and params
. The text
is the actual raw SQL query statement while the params
are any optional arguments that our query uses as variables to find and filter through our Postgres data.
// lib/queries.js
export const SECTIONS_QUERY = `SELECT
p.id AS page_id,
p.title AS page_title,
p.path AS page_path,
p.order_ AS page_order,
p.sectionid As page_sections_id,
p.seoid AS page_seo_id,
s.id AS section_id,
s.title AS section_title,
s.order_ AS section_order,
s.type AS section_type,
s.filter AS section_filter,
s.blockid AS section_block_id,
s.pageid AS section_page_id,
b.id AS block_id,
b.title AS block_title,
b.lead AS block_lead,
b.excerpt AS block_excerpt,
b.content AS block_content,
b.mediaid AS block_media_id,
b.sectionid AS block_section_id,
m.id AS media_id,
m.title AS media_title,
m.url AS media_url,
m.alt AS media_alt,
m.blockid AS media_block_id,
m.seoid AS media_seo_id,
seo.id AS seo_id,
seo.title AS seo_title,
seo.description AS seo_description,
seo.keywords AS seo_keywords,
seo.sitename AS seo_sitename,
seo.url AS seo_url,
seo.mediaid AS seo_media_id,
seo.pageid AS seo_page_id
FROM
pages AS p
LEFT JOIN seo ON seo.id = ANY (p.seoid)
LEFT JOIN sections AS s ON s.id = ANY (p.sectionid)
LEFT JOIN blocks AS b ON b.id = ANY (s.blockid)
LEFT JOIN media AS m ON m.id = ANY (b.mediaid)
WHERE
p.title IN ('launch')
ORDER BY p.id ASC, s.order_ ASC`;
// lib/pg.js
const db = require("./pg-pool");
const { SECTIONS_QUERY } = require("./queries");
async function getSections() {
// used for the Splash Page => airtable data
try {
// query database for sections and all related data.
const res = await db.query(SECTIONS_QUERY);
// handle success
return res;
} catch (err) {
// handle errors
console.error(err);
}
}
module.exports = {
getSections,
};
☝️ If you've been following along you'll recall we did the same thing in the last article using Table Plus, but you can use any database management tool that supports Postgres and can execute queries.
⚠️ If you need instructions on the setup again, you can also refer to SyncInc's guide: Connecting to your Postgres database.
SELECT
p.id AS page_id,
p.title AS page_title,
p.sectionid AS page_sections_id,
p.seoid AS pages_seo_id,
s.id AS section_id,
s.title AS section_title,
s.order_ AS section_order,
s.type AS section_type,
s.filter AS section_filter,
s.blockid AS section_block_id,
b.id AS block_id,
b.order_ AS block_order,
b.title AS block_title,
b.lead AS block_lead,
b.excerpt AS block_excerpt,
b.content AS block_content,
b.bullets AS block_bullets,
b.mediaid AS block_media_id,
b.linkid AS block_link_id,
m.id AS media_id,
m.title AS media_title,
m.url AS media_url,
m.alt AS media_alt,
m.blockid AS media_block_id,
m.seoid AS media_seo_id,
links.id AS link_id,
links.title AS link_title,
links.description AS link_description,
links.href AS link_href,
links.type AS link_type,
links.is_external AS link_is_externla,
links.blockid AS link_block_id,
seo.id AS seo_id,
seo.title AS seo_title,
seo.description AS seo_description,
seo.keywords AS seo_keywords,
seo.sitename AS seo_sitename,
seo.url AS seo_url,
seo.mediaid AS seo_media_id
FROM
pages AS p
LEFT JOIN seo ON seo.id = ANY (p.seoid)
LEFT JOIN sections AS s ON s.id = ANY (p.sectionid)
LEFT JOIN blocks AS b ON b.id = ANY (s.blockid)
LEFT JOIN media AS m ON m.id = ANY (b.mediaid)
LEFT JOIN links ON links.id = ANY (b.linkid)
WHERE
p.title IN('landing')
ORDER BY
s.order_ ASC;
WHERE
clause to help target the specific page we want the data for. // lib/queries.js
export const GET_PAGE_QUERY = `SELECT
p.id AS page_id,
p.title AS page_title,
p.sectionid AS page_sections_id,
p.seoid AS pages_seo_id,
s.id AS section_id,
s.title AS section_title,
s.order_ AS section_order,
s.type AS section_type,
s.filter AS section_filter,
s.blockid AS section_block_id,
b.id AS block_id,
b.order_ AS block_order,
b.title AS block_title,
b.lead AS block_lead,
b.excerpt AS block_excerpt,
b.content AS block_content,
b.bullets AS block_bullets,
b.mediaid AS block_media_id,
b.linkid AS block_link_id,
m.id AS media_id,
m.title AS media_title,
m.url AS media_url,
m.alt AS media_alt,
m.blockid As media_block_id,
m.seoid AS media_seo_id,
links.id AS link_id,
links.title AS link_title,
links.description AS link_description,
links.href AS link_href,
links.type AS link_type,
links.is_external AS link_is_externla,
links.blockid AS link_block_id,
seo.id AS seo_id,
seo.title AS seo_title,
seo.description AS seo_description,
seo.keywords AS seo_keywords,
seo.sitename AS seo_sitename,
seo.url AS seo_url,
seo.mediaid AS seo_media_id
FROM
pages AS p
LEFT JOIN seo ON seo.id = ANY (p.seoid)
LEFT JOIN sections AS s ON s.id = ANY (p.sectionid)
LEFT JOIN blocks AS b ON b.id = ANY (s.blockid)
LEFT JOIN media AS m ON m.id = ANY (b.mediaid)
LEFT JOIN links ON links.id = ANY (b.linkid)
WHERE
p.title IN($1)
ORDER BY s.order_ ASC`;
Once again, if you look closely you'll notice that we're no longer hard-coding in our page name like we did when testing our query in Table Plus. Instead, we've created a SQL variable with the $1
syntax inside of the WHERE
clause. This allows us to populate this query with the title of the page that we need data for.
And since we expect the same data shape for each page we can easily make this query reusable.
// lib/pg.js
const { GET_PAGE_QUERY } = require("./queries");
/* content truncated for brevity */
// get all page data for a single page and returns Airtable data
async function getPage(page = "landing") {
// defaults to landing page,
try {
// query database uwing query argument: page
// lists all records related to a specific page
return db.query(GET_PAGE_QUERY, [page]);
// handle success
} catch (err) {
console.error(err);
}
}
// be sure to export the getPage function:
module.exports = {
getSections,
getPage,
};
NOTE: Although the current GET_PAGE_QUERY
only declares a single variable, we can actually define as many as we need for our queries.
As you can see from the following:
return db.query(GET_PAGE_QUERY, [page]); // 2nd arg: [params]
The 2nd argument to the query method takes an array of params, used to populate the values for the variables in our query. In our case, this is the title
of the page we are querying for.
Currently, we only have that single variable declared within our query so we only need to populate the array with a single param, but you could just as easily declare multiple variables and then include multiple params in the array when necessary.
Each of the params maps to a variable by its position in the array. So the first param maps to $1
and the next would map to $2
and so on for as many variables you need to declare.
getStaticProps()
function to asynchronously grab our data while our page is being built.getPage()
to query our cloud Postgres instance for the data we need.🕥 *RECALL: * Next.js automatically executes any logic defined in getStaticProps()
function from any Page Component file at build-time.
If you're confident with Next.js and what you've learned so far, I urge you to give the following a try on your own.
Create a new file pages/landing.js
for your landing page.
Create and export a default Page Component from the /landing.js
page file.
Navigate to your page in the browser (http://localhost:3000/landing) and make sure you don't have any 404 errors.
Now export an asynchronous getStaticProps()
function and asynchronously import your getPage()
function inside of it.
Execute the getPage()
function and only return the rows
array from the response as props from getStaticProps()
.
Handle the props you returned from getStaticProps()
in your Page Component and use JSON.stringify(props.rows)
to dump the data out onto the page.
// pages/landing.js
export default function Landing({ page }) {
return page && <div>{JSON.stringify(page)}</div>;
}
☝️ Remember to make sure that the Page Component is the default export from this file.
getStaticProps()
function.// pages/landing.js
export default function Landing({ page }) {
return page && <div>{JSON.stringify(page)}</div>;
}
export async function getStaticProps() {
return {
props: {
page: [];
},
};
}
getStaticProps()
function.// pages/landing.js
export default function Landing({ page }) {
return page && <div>{JSON.stringify(page)}</div>;
}
export async function getStaticProps() {
const { getPage } = await import("../lib/pg");
const response = await getPage("landing");
return {
props: {
page: response.rows;
},
};
}
PageBuild()
function before we return it from getStaticProps()
as props.PageBuild()
function to see what it does for us - open up the file utils/data-helpers.js
:// utils/data-helpers.js
export function PageBuild(rows) {
// transforms page data
const seo = new Seo(rows[0]);
return {
seo,
page: rows?.length && rows?.map((row) => new Row(row)),
};
}
From the code block above, it's clear that most of the logic that actually handles the transforms is defined within the Seo()
factory function, and the logic that transforms the page rows is likely defined in the Row()
factory function.
⚠️ Both of these factory functions are located in the same file, feel free to browse through them so that you understand how the data is being transformed, it's quite straight forward so we're going to skip over displaying them here. But here's how they're implemented in our PageBuild()
function...
First, we grab the first item of the rows array and simply pluck out the SEO-related metadata for this page using the Seo() factory function.
Next, we iterate over each item in the rows array and return a new row in the exact shape that we expect our data to be in using the Row()
factory function.
We then return both the seo
object and page
array back.
getStaticProps()
function:// pages/landing.js
import { PageBuild } from "../utils/data-helpers";
export default function Landing({ page }) {
return <div>{JSON.stringify(page)}</div>;
}
export async function getStaticProps() {
const { getPage } = await import("../lib/pg");
const response = await getPage("landing");
const page = PageBuild(response?.rows);
return {
props: {
page,
},
};
}
getStaticProps()
function and implemented all of the data-fetching logic we'll need. page
array.components/landing
directory..
├── components
│ ├── Containers.js
│ ├── Link.js
│ ├── MarkdownJsx.js
│ ├── SEO.js
│ ├── landing
│ │ ├── BenefitsCards.js
│ │ ├── BrandList.js
│ │ ├── Card.js
│ │ ├── FeaturedBenefit.js
│ │ ├── FooterCta.js
│ │ ├── Header.js
│ │ ├── Hero.js
│ │ ├── Pricing.js
│ │ ├── RiskCta.js
│ │ ├── SectionHeading.js
│ │ ├── Testimonial.js
│ │ └── index.js
👀 NOTE: If you haven't done so already be sure to take a look at the Project Demo so you have a better idea of what you're building.
SEO
component. Unlike the previous articles, this time around we'll be using a custom SEO
component that has already been created for you, but let's take a look at the component itself, and see what it does for us.// components/SEO.js
import Head from "next/head";
export const SEO = ({ seo }) => {
return (
<Head>
<title>{seo.title}</title>
<meta name='viewport' content='initial-scale=1.0, width=device-width' />
<meta name='description' content={seo.description} />
<meta property='og:title' content={seo.title} />
<meta property='og:site_name' content={seo.sitename} />
<meta property='og:url' content={seo.url} />
<meta property='og:description' content={seo.description} />
<meta property='og:image' content={seo.url} />
<link rel='icon' href='/favicon.ico' />
<link rel='cannonical' href={seo.url} />
</Head>
);
};
The SEO component takes in a single prop - - a seo
object, and we then use it to populate all of the metadata for our page.
SEO
component is pretty straightforward thanks to our factory functions that shaped our data for us.// pages/landing.js
import { SEO } from "../components/SEO";
export default function Landing({ page }) {
const { seo, page: rows } = page;
return (
<>
<SEO seo={seo} />
<div>{JSON.stringify(page)}</div>;
</>
);
}
// pages/landing.js
import { Header } from "../components/landing";
import styles from "../styles/Landing.module.css";
export default function Landing({ page }) {
const { seo, page: rows } = page;
return (
<>
<SEO seo={seo} />
<div className={styles.pageWrapper}>
<Header brandName={seo?.title} />
<main className={styles.main}>Test</main>
<footer className={styles.footer}>
{" "}
©
<span>Uptime Sentry</span> {new Date().getFullYear()}
</footer>
</div>
</>
);
}
sections
that use multiple blocks
to render the appropriate data. // utils/data-helpers.js
export function groupBy(arr, key) {
// groups objests in array into grouped arrays based on matching key
return arr.reduce(function (rv, x) {
(rv[x[key]] = rv[x[key]] || []).push(x);
return rv;
}, {});
}
export function sortRows(rows) {
// used to group and camelCase an array of airtable rows, by their matching titles
const interim = groupBy(rows, "title");
return Object.keys(interim)?.map((key) => {
const newKey = camelize(key);
return {
[newKey]: interim[key],
};
});
}
sortRows()
takes in all of our rows and iterates over them using the groupBy()
function to group them by matching titles which results in a single object for each row with the title
of each row used as the key. We then take that object, and transform the keys from the kebab-cased titles to camel-cased property names and finally return an array of sorted rows.// pages/landing.js
import { PageBuild, sortRows } from "../utils/data-helpers";
export default function Landing({ page }) {
const { seo, page: rows } = page;
const sortedRows = sortRows(rows);
console.log("🚀 ~ file: landing.js sortedRows", sortedRows)
return (
<>
{/* truncated for brevity */}
</>
);
}
Hero
component, where we'll just de-structure the data we need right from the sortedRows
array.import { Container, Section, Row } from "../components/Containers";
import { Header, Hero } from "../components/landing";
export default function Landing({ page }) {
const { seo, page: rows } = page;
// pluck the hero component's data out of the array.
const [{landingHero}] = sortRows(rows);
return (
<>
<SEO seo={seo} />
<div className={styles.pageWrapper}>
<Header brandName={seo?.title} />
<Section style={{ padding: "2em", textAlign: "center" }}>
<Hero block={landingHero[0]?.block} />
</Section>
<footer className={styles.footer}>
{" "}
©
<span>Uptime Sentry</span> {new Date().getFullYear()}
</footer>
</div>
</>
);
}
We simply pass in the block
related to the Hero
component as props, the component will handle the rest.
👀 If you're curious as to what the Hero
component is doing, feel free to take a look at the file: components/landing/Hero.js
, but once again since it's pretty straightforward, we're not going to go over it in detail here.
BrandList
component that we use as social proof.// pages/landing.js
import { BrandList, Header, Hero } from "../components/landing";
export default function Landing({ page }) {
const { seo, page: rows } = page;
const [{ landingHero }, { landingBrandList }] = sortRows(rows);
return (
<>
<SEO seo={seo} />
<div className={styles.pageWrapper}>
<Header brandName={seo?.title} />
<main className={styles.main}>
<Section style={{ padding: "2em", textAlign: "center" }}>
<Hero block={landingHero[0]?.block} />
<Container>
<BrandList data={landingBrandList} />
</Container>
</Section>
</main>
<footer className={styles.footer}>
©
<span>Uptime Sentry</span> {new Date().getFullYear()}
</footer>
</div>
</>
);
}
// pages/landing.js
import { BenefitsCards, BrandList, Header, Hero } from "../components/landing";
export default function Landing({ page }) {
const { seo, page: rows } = page;
const [
{ landingHero },
{ landingBrandList },
{ landingBenefitsIntro },
{ landingBenefits },
] = sortRows(rows);
return (
<>
<SEO seo={seo} />
<div className={styles.pageWrapper}>
<Header brandName={seo?.title} />
<main className={styles.main}>
{/* truncated for brevity */}
<Section className='secondary-light'>
<BenefitsCards
headingBlock={landingBenefitsIntro[0].block}
// sort by block order ASC
data={landingBenefits.sort((a, b) =>
a.block.order > b.block.order ? 1 : -1
)}
/>
</Section>
</main>
{/* truncated for brevity */}
</div>
</>
);
}
The BenefitsCards
components render both the landingBenefitsIntro
and landingBenefits
sections.
☝️You might've noticed that we sort the data according to the block.order
field before we pass it in as props.
featuredTestimonial
in this same section, but first, we de-structure it off of the landingFeaturedTestimonial
array.// pages/landing.js
import {
BenefitsCards,
BrandList,
Header,
Hero,
Testimonial,
} from "../components/landing";
export default function Landing({ page }) {
const { seo, page: rows } = page;
const [
{ landingHero },
{ landingBrandList },
{ landingBenefitsIntro },
{ landingBenefits },
{ landingFeaturedTestimonial },
] = sortRows(rows);
// we'll be using this in multiple places, so we'll store it for easy access.
const [featuredTestimonial] = landingFeaturedTestimonial;
return (
<>
<SEO seo={seo} />
<div className={styles.pageWrapper}>
<Header brandName={seo?.title} />
<main className={styles.main}>
{/* truncated for brevity */}
<Section className='secondary-light'>
<BenefitsCards
headingBlock={landingBenefitsIntro[0].block}
data={landingBenefits.sort((a, b) =>
a.block.order > b.block.order ? 1 : -1
)}
/>
<Testimonial block={featuredTestimonial.block} />
</Section>
</main>
{/* truncated for brevity */}
</div>
</>
);
}
FeaturedBenefit
blocks in total that we'll render accordingly:// pages/landing.js
import {
BenefitsCards,
BrandList,
FeaturedBenefit,
Header,
Hero,
Testimonial,
} from "../components/landing";
export default function Landing({ page }) {
const { seo, page: rows } = page;
const [
{ landingHero },
{ landingBrandList },
{ landingBenefitsIntro },
{ landingBenefits },
{ landingFeaturedTestimonial },
{ landingFeaturedBenefit1 },
{ landingFeaturedBenefit2 },
{ landingFeaturedBenefit3 },
] = sortRows(rows);
// we'll be using this in multiple places, so we'll store it for easy access.
const [featuredTestimonial] = landingFeaturedTestimonial;
return (
<>
<SEO seo={seo} />
<div className={styles.pageWrapper}>
<Header brandName={seo?.title} />
<main className={styles.main}>
{/* truncated for brevity */}
<Section>
<Container>
<FeaturedBenefit
block={landingFeaturedBenefit1[0].block}
alternate={landingFeaturedBenefit1[0]["filter"].includes(
"reverse"
)}
/>
</Container>
<div style={{ width: "100%", background: "var(--primary-light)" }}>
<Container>
<FeaturedBenefit
block={landingFeaturedBenefit2[0].block}
alternate={landingFeaturedBenefit2[0]["filter"].includes(
"reverse"
)}
/>
</Container>
</div>
<Container>
<FeaturedBenefit
block={landingFeaturedBenefit3[0].block}
alternate={landingFeaturedBenefit3[0]["filter"].includes(
"reverse"
)}
/>
</Container>
</Section>
</main>
{/* truncated for brevity */}
</div>
</>
);
}
rich text
for the bullets
field. rich text
fields are delivered to us as a markdown string. Now let's take a look at how we can render that markdown content.// components/landing/FeaturedBenefit.js
import Image from "next/image";
import { Row } from "../Containers";
import { MarkdownJSX } from "../MarkdownJsx";
import styles from "../../styles/landing/FeaturedBenefits.module.css";
export const FeaturedBenefit = ({ block, alternate, data }) => {
return (
<>
<Row
className={[styles?.benefit, alternate ? styles?.alternate : ""].join(
" "
)}
data-test={data}
>
<Image
src={block?.media?.url[0]}
alt={block.media?.alt}
layout='intrinsic'
width={1180}
height={920}
/>
<div className={styles.content}>
<h3>{block.title}</h3>
<p>{block.excerpt}</p>
<MarkdownJSX
className={styles.markdown}
markdown={block.bullets}
overrides={{
ul: (props) => <ul className={styles.ul} {...props} />,
li: (props) => <li className={styles.li} {...props} />,
}}
/>
</div>
</Row>
</>
);
};
For the most part, this component is like any other, but you might not be familiar with our approach to rendering our bulleted lists from markdown content.
MarkdownJSX
which is essentially a wrapper around the third-party library: markdown-to-jsx.// components/MarkdownJsx.js
import Markdown from "markdown-to-jsx";
import styles from "../styles/Markdown.module.css";
export const MarkdownJSX = ({
markdown = "",
type = "list",
highlight = false,
overrides,
...rest
}) => {
return (
<Markdown
className={styles.markdown}
{...rest}
options={{
overrides: {
// overrides allow defining of specific components to be used to render each element
span: (props) => (
<span className={styles.span} {...props} {...rest.span} />
),
ul: (props) => <ul className={styles.ul} {...props} {...rest.ul} />,
li: (props) => (
<li
className={styles.li}
style={highlight ? { ...highlightStyles } : {}}
{...props}
{...rest.li}
/>
),
},
}}
>
{markdown}
</Markdown>
);
};
const highlightStyles = {
fontWeight: "bold",
fontSize: "medium",
};
markdown
prop that expects "stringified" markdown content. We then take that markdown content and render it as a child of the component.Markdown
component is where all the magic happens. This is where we define custom components used as overrides
to render each of the elements we expect to be passed in as markdown content.span
for situations where our content is wrapped in a spanul
for the unordered listli
for each of the list items.rich-text
content we get back from Airtable, let's move on to rendering our last few sections of this page.RiskCta
, the only caveat here is that we're still rendering it inside the same section
element as the FeaturedBenefit
components.// pages/landing.js
import {
BenefitsCards,
BrandList,
FeaturedBenefit,
Header,
Hero,
RiskCta,
Testimonial,
} from "../components/landing";
export default function Landing({ page }) {
const { seo, page: rows } = page;
const [
{ landingHero },
{ landingBrandList },
{ landingBenefitsIntro },
{ landingBenefits },
{ landingFeaturedTestimonial },
{ landingFeaturedBenefit1 },
{ landingFeaturedBenefit2 },
{ landingFeaturedBenefit3 },
{ landingCtaRisk },
] = sortRows(rows);
const [featuredTestimonial] = landingFeaturedTestimonial;
return (
<>
<SEO seo={seo} />
<div className={styles.pageWrapper}>
<Header brandName={seo?.title} />
<main className={styles.main}>
{/* truncated for brevity */}
<Section>
{/* truncated for brevity */}
{/*
☝️ the RiskCta component gets rendered in the same section as FeaturedBenefits */}
<div style={{ width: "100%", background: "var(--secondary-light)" }}>
<RiskCta block={landingCtaRisk[0].block} />
</div>
</Section>
</main>
{/* truncated for brevity */}
</div>
</>
);
}
// pages/landing.js
import {
BenefitsCards,
BrandList,
FeaturedBenefit,
Header,
Hero,
Pricing,
RiskCta,
Testimonial,
} from "../components/landing";
export default function Landing({ page }) {
const { seo, page: rows } = page;
const [
{ landingHero },
{ landingBrandList },
{ landingBenefitsIntro },
{ landingBenefits },
{ landingFeaturedTestimonial },
{ landingFeaturedBenefit1 },
{ landingFeaturedBenefit2 },
{ landingFeaturedBenefit3 },
{ landingCtaRisk },
{ landingPricing },
] = sortRows(rows);
const [featuredTestimonial] = landingFeaturedTestimonial;
return (
<>
<SEO seo={seo} />
<div className={styles.pageWrapper}>
<Header brandName={seo?.title} />
<main className={styles.main}>
{/* truncated for brevity */}
<Section>
<Container>
<Pricing data={landingPricing} />
</Container>
</Section>
</main>
{/* truncated for brevity */}
</div>
</>
);
}
The Pricing
component also renders a markdown list just like we did with the FeaturedBenefit
Component. And we implement a bit of logic to emphasize the perferred pricing plan by styling it just a bit differently to make it stand out amongst the rest of the options.
Pricing
component is rendered using several smaller components which allows us to offload some of the rendering responsibility to components where it makes sense.// components/landing/Pricing.js
import { Link } from "../Link";
import { Container, Row } from "../Containers";
import { MarkdownJSX } from "../MarkdownJsx";
import styles from "../../styles/landing/Pricing.module.css";
export const PricesHeading = ({ block }) => {
return (
<Container className={styles.prices_heading}>
<h2>{block.title}</h2>
<p>{block.excerpt}</p>
</Container>
);
};
export const Pricing = ({ data, render = renderPricingCard }) => {
// sort data by "block.order" so that we can ensure the first item is always the heading
const [pricesHeading, ...restPricing] = data.sort((a, b) =>
a.block.order > b.block.order ? 1 : -1
);
return (
<>
<PricesHeading block={pricesHeading.block} />
<Container className={styles.prices}>
<Row className='fluid'>
{/*
the restPricing array will always have 3 prices in it
-- use sort to target the middle price as the featured price
-- then immediately iterate over that sorted array and render each PricingCard
*/}
{restPricing.sort((a, b) => (a.title > b.title ? -1 : 1)).map(render)}
</Row>
</Container>
</>
);
};
export const PricingCard = ({ pricing, isFeatured }) => {
return (
<>
<div
className={styles.price_card}
style={{
width: isFeatured ? "1200px" : "100%",
border: isFeatured ? "2px solid var(--secondary)" : "none",
}}
>
<div className={styles.pricepoint}>
<p>{pricing.block.lead}</p>
<h2>${pricing.block.title}</h2>
<small>{pricing.block.excerpt}</small>
<br />
<p className={styles.excerpt}>{pricing.block.content}</p>
</div>
<MarkdownJSX
markdown={pricing.block.bullets}
type='pricing'
highlight={isFeatured}
/>
<div>
<Link
className={`button primary ${isFeatured ? "" : "outline"}`}
href={pricing.block.links.href}
>
{pricing.block.links.title}
</Link>
</div>
</div>
</>
);
};
function renderPricingCard(pricing, i) {
return (
<PricingCard
key={`${pricing.id}-${i}`}
pricing={pricing}
// price at index: 1 is the featured price value.
isFeatured={i === 1} // used to highlight the featured pricepoint
/>
);
}
Most of the logic here is handled by the Pricing
component, where we sort the data accordingly by block.order
and render the PricesHeading
and each of the PricingCard
components.
☝️You might've noticed that we sort our prices by title
before we render each PricingCard
. This is because we use the title
field in our Airtable base to populate the actual price value. So essentially when we sort by title
we're actually sorting by price point.
// pages/landing.js
import {
BenefitsCards,
BrandList,
FeaturedBenefit,
FooterCta,
Header,
Hero,
Pricing,
RiskCta,
Testimonial,
} from "../components/landing";
export default function Landing({ page }) {
const { seo, page: rows } = page;
const [
{ landingHero },
{ landingBrandList },
{ landingBenefitsIntro },
{ landingBenefits },
{ landingFeaturedTestimonial },
{ landingFeaturedBenefit1 },
{ landingFeaturedBenefit2 },
{ landingFeaturedBenefit3 },
{ landingCtaRisk },
{ landingPricing },
{ landingCtaUrgency },
] = sortRows(rows);
const [featuredTestimonial] = landingFeaturedTestimonial;
return (
<>
<SEO seo={seo} />
<div className={styles.pageWrapper}>
<Header brandName={seo?.title} />
<main className={styles.main}>
{/* truncated for brevity */}
<Section className='primary-lighter'>
<Container style={{ margin: "4em auto" }}>
<FooterCta block={landingCtaUrgency[0].block} />
</Container>
</Section>
</main>
{/* truncated for brevity */}
</div>
</>
);
}
pages/landing.js
file, take a look at this if you need to compare your work or get stuck.// pages/landing.js
import { SEO } from "../components/SEO";
import { Container, Section, Row } from "../components/Containers";
import {
BrandList,
BenefitsCards,
FeaturedBenefit,
Header,
Hero,
Pricing,
RiskCta,
Testimonial,
FooterCta,
} from "../components/landing";
import { PageBuild, sortRows } from "../utils/data-helpers";
import styles from "../styles/Landing.module.css";
export default function Landing({ page = {} }) {
const { seo, page: rows } = page;
const [
{ landingHero },
{ landingBrandList },
{ landingBenefitsIntro },
{ landingBenefits },
{ landingFeaturedTestimonial },
{ landingFeaturedBenefit1 },
{ landingFeaturedBenefit2 },
{ landingFeaturedBenefit3 },
{ landingCtaRisk },
{ landingPricing },
{ landingCtaUrgency },
] = sortRows(rows);
const [featuredTestimonial] = landingFeaturedTestimonial;
return (
<div className={styles.pageWrapper}>
<SEO seo={seo} />
<Header brandName={seo?.title} />
<main className={styles.main}>
<Section style={{ padding: "2em", textAlign: "center" }}>
<Hero block={landingHero[0]?.block} />
<Container>{<BrandList data={landingBrandList} />}</Container>
</Section>
<Section className='secondary-light'>
<BenefitsCards
headingBlock={landingBenefitsIntro[0].block}
// sort by block order ASC
data={landingBenefits.sort((a, b) =>
a.block.order > b.block.order ? 1 : -1
)}
/>
<Testimonial block={featuredTestimonial.block} />
</Section>
<Section>
<Container>
<FeaturedBenefit
block={landingFeaturedBenefit1[0].block}
alternate={landingFeaturedBenefit1[0]["filter"].includes(
"reverse"
)}
/>
</Container>
<div style={{ width: "100%", background: "var(--primary-light)" }}>
<Container>
<FeaturedBenefit
block={landingFeaturedBenefit2[0].block}
alternate={landingFeaturedBenefit2[0]["filter"].includes(
"reverse"
)}
/>
</Container>
</div>
<Container>
<FeaturedBenefit
block={landingFeaturedBenefit3[0].block}
alternate={landingFeaturedBenefit3[0]["filter"].includes(
"reverse"
)}
/>
</Container>
<div style={{ width: "100%", background: "var(--secondary-light)" }}>
{/* <Container> */}
<RiskCta block={landingCtaRisk[0].block} />
{/* </Container> */}
</div>
</Section>
<Section>
<Container>
<Pricing data={landingPricing} />
</Container>
</Section>
<Section className='primary-lighter'>
<Container style={{ margin: "4em auto" }}>
<FooterCta block={landingCtaUrgency[0].block} />
</Container>
</Section>
</main>
<footer className={styles.footer}>
{" "}
©
<span>Uptime Sentry</span> {new Date().getFullYear()}
</footer>
</div>
);
}
export async function getStaticProps() {
const { getPage } = await import("../lib/pg");
const response = await getPage("landing");
const page = PageBuild(response?.rows);
return {
props: {
page,
},
};
}
All of the content you need is already included in the testimonials table, so give yourself a challenge and see if you can create this on your own.
v11.0.0
which introduced several image support enhancements such as blurred placeholders. If you'd like I suggest upgrading the project to use v.11.0.0
. Try to challenge yourself and add blurred image placeholders to your project.