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! ๐Ÿ™Œ

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

By Luciano Mammino

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

Imagine you are hosting a private event and you want to create a website to invite all your guests. Of course, you'd like to have an easy way to just share a URL with every guest and they should be able to access all the details of the event. Everyone else should not be allowed to see the page. Even nicer if the website is customized for every guest and if you could use the same website to collect information from the guests (who is coming and who is not). Ok, how do we build all of this? But, most importantly, how do we build it quickly? How do we keep it simple and possibly host it 100% for FREE? I had to do something like this recently so, in this talk, I am going to share my solution, which involves a React SPA (built with Next.js & Vercel) and AirTable as a backend!

  • 3,206