Building an invite-only microsite
with Next.js & Airtable

Luciano Mammino (@loige)

React Dublin

Meetup

META_SLIDE!

๐Ÿฆธโ€โ™€๏ธ Our mission today:

Let's Build an invite-only website!

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@latest --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 โฑ

Deployment ๐Ÿšข

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 Aaron Doucett on Unsplash

THANKS! ๐Ÿ™Œ