building an invite-only Microsite
with Next.js and Airtable!

Luciano Mammino (@loige)

Global Summit for Node.js'23
2023-01-25

META_SLIDE!

15 Seconds demo ⏱

(self-imposed) Requirements 👩‍🔬

  • 🏃‍♀️ Iterate quickly
  • 🧘‍♀️ Simple to host, maintain and update
  • 🪶 Lightweight backend
  • 👩‍🏫 Non-techy people can easily access the data
  • 💸 Cheap (or even FREE) hosting!

Let me introduce myself first...

👋 I'm Luciano (🇮🇹🍕🍝🤌)

👨‍💻 Senior Architect @ fourTheorem (Dublin 🇮🇪)

📔 Co-Author of Node.js Design Patterns  👉

Let's connect!

  loige.co (blog)

  @loige (twitter)

  loige (twitch)

  lmammino (github)

Always re-imagining

We are a pioneering technology consultancy focused on AWS and serverless

 

Accelerated Serverless | AI as a Service | Platform Modernisation

✉️ Reach out to us at  hello@fourTheorem.com

😇 We are always looking for talent: fth.link/careers

📒 AGENDA

  • Choosing the tech stack
  • The data flow
  • Using Airtable as a database
  • Creating APIs with Next.js and Vercel
  • Creating custom React Hooks
  • Using user interaction to update the data
  • Security considerations

Tech stack 🥞

Making a Next.js private

  • Every guest should see something different
  • People without an invite code should not be able to access any content

Access denied

Hello, Micky

you are invited...

1️⃣ Load React SPA

2️⃣ Code validation

3️⃣ View invite (or error)

STEP 1.

Let's organize the data in Airtable

Managing data

  • Invite codes are UUIDs
  • Every record contains the information for every guest (name, etc)

Airtable lingo

Base (project)

Table

Records

Fields

STEP 2.

Next.js scaffolding and Retrieving Invites

New next.js projects

npx create-next-app@12.2 --typescript --use-npm

(used Next.js 12.2)

Invite type

export interface Invite {
  code: string,
  name: string,
  favouriteColor: string,
  weapon: string,
  coming?: boolean,
}

Airtable SDK

npm i --save airtable
export AIRTABLE_API_KEY="put your api key here"
export AIRTABLE_BASE_ID="put your base id here"
// utils/airtable.ts

import Airtable from 'airtable'
import { Invite } from '../types/invite'

if (!process.env.AIRTABLE_API_KEY) {
  throw new Error('AIRTABLE_API_KEY is not set')
}
if (!process.env.AIRTABLE_BASE_ID) {
  throw new Error('AIRTABLE_BASE_ID is not set')
}

const airtable = new Airtable({ apiKey: process.env.AIRTABLE_API_KEY })
const base = airtable.base(process.env.AIRTABLE_BASE_ID)
export function getInvite (inviteCode: string): Promise<Invite> {
  return new Promise((resolve, reject) => {
    base('invites')
      .select({
        filterByFormula: `{invite} = ${escape(inviteCode)}`, // <- we'll talk more about escape
        maxRecords: 1
      })
      .firstPage((err, records) => {
        if (err) {
          console.error(err)
          return reject(err)
        }

        if (!records || records.length === 0) {
          return reject(new Error('Invite not found'))
        }

        resolve({
          code: String(records[0].fields.invite),
          name: String(records[0].fields.name),
          favouriteColor: String(records[0].fields.favouriteColor),
          weapon: String(records[0].fields.weapon),
          coming: typeof records[0].fields.coming === 'undefined'
            ? undefined
            : records[0].fields.coming === 'yes'
        })
      })
  })
}

STEP 3.

Next.js Invite API

APIs with Next.js

Files inside pages/api are API endpoints

// pages/api/hello.ts -> <host>/api/hello

import type { NextApiRequest, NextApiResponse } from 'next'

export default async function handler (
  req: NextApiRequest,
  res: NextApiResponse<{ message: string }>
) {
  return res.status(200).json({ message: 'Hello World' })
}
// pages/api/invite.ts
import { InviteResponse } from '../../types/invite'
import { getInvite } from '../../utils/airtable'

export default async function handler (
  req: NextApiRequest,
  res: NextApiResponse<InviteResponse | { error: string }>
) {
  if (req.method !== 'GET') {
    return res.status(405).json({ error: 'Method Not Allowed' })
  }
  if (!req.query.code) {
    return res.status(400).json({ error: 'Missing invite code' })
  }
  
  const code = Array.isArray(req.query.code) ? req.query.code[0] : req.query.code
  
  try {
    const invite = await getInvite(code)
    res.status(200).json({ invite })
  } catch (err) {
    if ((err as Error).message === 'Invite not found') {
      return res.status(401).json({ error: 'Invite not found' })
    }
    res.status(500).json({ error: 'Internal server error' })
  }
}
{
  "invite":{
    "code":"14b25700-fe5b-45e8-a9be-4863b6239fcf",
    "name":"Leonardo",
    "favouriteColor":"blue",
    "weapon":"Twin Katana"
  }
}
curl -XGET "http://localhost:3000/api/invite?code=14b25700-fe5b-45e8-a9be-4863b6239fcf"

Testing

STEP 4.

Invite validation in React

Attack plan 🤺

  • When the SPA loads:
    • We grab the invite code from the URL
    • We call the invite API with the code
      • ✅ If it's valid, we render the content
      • ❌ If it's invalid, we render an error

How do we manage this data fetching lifecycle? 😰

  • In-line in the top-level component (App)?
  • In a Context provider?
  • In a specialized React Hook? 🪝

How can we create a custom React Hook? 🤓

  • A custom Hook is a JavaScript function whose name starts with ”use” and that may call other Hooks
  • It doesn’t need to have a specific signature
  • Inside the function, all the common rules of hooks apply:
    • ​Only call Hooks at the top level

    • Don’t call Hooks inside loops, conditions, or nested functions

  • reactjs.org/docs/hooks-custom.html

// components/hooks/useInvite.tsx
import { useState, useEffect } from 'react'
import { InviteResponse } from '../../types/invite'
async function fetchInvite (code: string): Promise<InviteResponse> {
  // makes a fetch request to the invite api (elided for brevity)
}

export default function useInvite (): [InviteResponse | null, string | null] {
  const [inviteResponse, setInviteResponse] = useState<InviteResponse | null>(null)
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    const url = new URL(window.location.toString())
    const code = url.searchParams.get('code')

    if (!code) {
      setError('No code provided')
    } else {
      fetchInvite(code)
        .then(setInviteResponse)
        .catch(err => {
          setError(err.message)
        })
    }
  }, [])

  return [inviteResponse, error]
}
import React from 'react'
import useInvite from './hooks/useInvite'

export default function SomeExampleComponent () {
  const [inviteResponse, error] = useInvite()

  // there was an error
  if (error) {
    return <div>... some error happened</div>
  }

  // still loading the data from the backend
  if (!inviteResponse) {
    return <div>Loading ...</div>
  }

  // has the data!
  return <div>
    actual component markup when inviteResponse is available
  </div>
}

Example usage:

STEP 5.

Collecting user data

Changes required 🙎

  • Add the "coming" field in Airtable
  • Add a new backend utility to update the "coming" field for a given invite
  • Add a new endpoint to update the coming field for the current user
  • Update the React hook the expose the update functionality

New field

 ("yes", "no", or undefined)

Add the "coming" field in Airtable

// utils/airtable.ts
import Airtable, { FieldSet, Record } from 'airtable'
// ...

export function getInviteRecord (inviteCode: string): Promise<Record<FieldSet>> {
  // gets the raw record for a given invite, elided for brevity
}

export async function updateRsvp (inviteCode: string, rsvp: boolean): Promise<void> {
  const { id } = await getInviteRecord(inviteCode)

  return new Promise((resolve, reject) => {
    base('invites').update(id, { coming: rsvp ? 'yes' : 'no' }, (err) => {
      if (err) {
        return reject(err)
      }

      resolve()
    })
  })
}

RSVP Utility

// pages/api/rsvp.ts
type RequestBody = {coming?: boolean}

export default async function handler (
  req: NextApiRequest,
  res: NextApiResponse<{updated: boolean} | { error: string }>
) {
  if (req.method !== 'PUT') {
    return res.status(405).json({ error: 'Method Not Allowed' })
  }
  if (!req.query.code) {
    return res.status(400).json({ error: 'Missing invite code' })
  }
  const reqBody = req.body as RequestBody
  if (typeof reqBody.coming === 'undefined') {
    return res.status(400).json({ error: 'Missing `coming` field in body' })
  }
  const code = Array.isArray(req.query.code) ? req.query.code[0] : req.query.code

  try {
    await updateRsvp(code, reqBody.coming)
    return res.status(200).json({ updated: true })
  } catch (err) {
    if ((err as Error).message === 'Invite not found') {
      return res.status(401).json({ error: 'Invite not found' })
    }
    res.status(500).json({ error: 'Internal server error' })
  }
}

RSVP API Endpoint

// components/hooks/useInvite.tsx
// ...

interface HookResult {
  inviteResponse: InviteResponse | null,
  error: string | null,
  updating: boolean,
  updateRsvp: (coming: boolean) => Promise<void>
}

async function updateRsvpRequest (code: string, coming: boolean): Promise<void> {
  // Helper function that uses fetch to invoke the rsvp API endpoint (elided)
}

useinvite Hook v2

// ...
export default function useInvite (): HookResult {
  const [inviteResponse, setInviteResponse] = useState<InviteResponse | null>(null)
  const [error, setError] = useState<string | null>(null)
  const [updating, setUpdating] = useState<boolean>(false)

  useEffect(() => {
    // load the invite using the code from URL, same as before
  }, [])

  async function updateRsvp (coming: boolean) {
    if (inviteResponse) {
      setUpdating(true)
      await updateRsvpRequest(inviteResponse.invite.code, coming)
      setInviteResponse({
        ...inviteResponse,
        invite: { ...inviteResponse.invite, coming }
      })
      setUpdating(false)
    }
  }

  return { inviteResponse, error, updating, updateRsvp }
}
import useInvite from './hooks/useInvite'

export default function Home () {
  const { inviteResponse, error, updating, updateRsvp } = useInvite()

  if (error) { return <div>Duh! {error}</div> }
  if (!inviteResponse) { return <div>Loading...</div> }

  function onRsvpChange (e: ChangeEvent<HTMLInputElement>) {
    const coming = e.target.value === 'yes'
    updateRsvp(coming)
  }

  return (<fieldset disabled={updating}><legend>Are you coming?</legend>
    <label htmlFor="yes">
      <input type="radio" id="yes" name="coming" value="yes"
        onChange={onRsvpChange}
        checked={inviteResponse.invite.coming === true}
      /> YES
    </label>
    <label htmlFor="no">
      <input type="radio" id="no" name="coming" value="no"
        onChange={onRsvpChange}
        checked={inviteResponse.invite.coming === false}
      /> NO
    </label>
  </fieldset>)
}

Using the hook

15 Seconds demo ⏱

Security considerations 🥷

What if I don't have an invite code and I want to hack into the website anyway?

Disclosing sensitive information in the source code

How do we fix this?

  • Don't hardcode any sensitive info in your JSX (or JS in general)
  • Use the invite API to return any sensitive info (together with the user data)
  • This way, the sensitive data is available only in the backend code

Airtable filter formula injection

export function getInvite (inviteCode: string): Promise<Invite> {
  return new Promise((resolve, reject) => {
    base('invites')
      .select({
        filterByFormula: `{invite} = ${escape(inviteCode)}`,
        maxRecords: 1
      })
      .firstPage((err, records) => {
        // ...
      })
  })
}

Airtable filter formula injection

export function getInvite (inviteCode: string): Promise<Invite> {
  return new Promise((resolve, reject) => {
    base('invites')
      .select({
        filterByFormula: `{invite} = '${inviteCode}'`,
        maxRecords: 1
      })
      .firstPage((err, records) => {
        // ...
      })
  })
}

inviteCode is user controlled!

The user can change this value arbitrarily! 😈

So what?!

If the user inputs the following query string:

?code=14b25700-fe5b-45e8-a9be-4863b6239fcf

We get the following filter formula

{invite} = '14b25700-fe5b-45e8-a9be-4863b6239fcf'

👌

So what?!

But, if the user inputs this other query string: 😈

?code=%27%20>%3D%200%20%26%20%27

Which is basically the following unencoded query string:

{invite} = '' >= 0 & ''

😰

?code=' >= 0 & '

Now we get:

which is TRUE for EVERY RECORD!

How do we fix this?

  • The escape function "sanitizes" user input to try to prevent injection
  • Unfortunately Airtable does not provide an official solution for this, so this escape function is the best I could come up with, but it might not be "good enough"! 😔
function escape (value: string): string {
  if (value === null || 
      typeof value === 'undefined') {
    return 'BLANK()'
  }

  if (typeof value === 'string') {
    const escapedString = value
      .replace(/'/g, "\\'")
      .replace(/\r/g, '')
      .replace(/\\/g, '\\\\')
      .replace(/\n/g, '\\n')
      .replace(/\t/g, '\\t')
    return `'${escapedString}'`
  }

  if (typeof value === 'number') {
    return String(value)
  }

  if (typeof value === 'boolean') {
    return value ? '1' : '0'
  }

  throw Error('Invalid value received')
}

Let's wrap things up... 🌯

Limitations

Airtable API rate limiting: 5 req/sec 😰

(We actually do 2 calls when we update a record!)

Possible Alternatives 🤷‍♀️

Google spreadsheet (there's an API and a package)

DynamoDB (with Amplify)

Firebase (?)

Any headless CMS (?)

Supabase or Strapi (?)

Takeaways

  • This solution is a quick, easy, and cheap way to build invite-only websites.
  • We learned about Next.js API endpoints, custom React Hooks, how to use AirTable (and its SDK), and a bunch of security related things.
  • Don't use this solution blindly: evaluate your context and find the best tech stack!

Also available as an Article

With the full codebase on GitHub!

Cover Photo by Francesco Ungaro on Unsplash

TNX 🍕❤️