Skip to content
On this page

Developer Blog

Building a Wedding Invite System with Node.js, Vonage, and Directus

Published August 11th, 2023

Written By
Kevin Lewis
Kevin Lewis
Director, Developer Relations
With Thanks ToEsther Agbaje

My wedding is coming up in just over a week, which is exciting. But you know what isn't exciting? Getting a confirmed guest list with all of the variations in invites and information gathered consistently. I'm going to show you how I built my wedding invite system with various types of invite (all-day/ceremony-only), auto-formatted contact information when RSVPs were submitted, and generated a constantly-updated guest-list.

Before You Start

You will need Node.js and a text editor installed on your computer.

You will also need a Directus project running - either using Directus Cloud or by Self Hosting - and an API token for your admin user.

Finally, sign up for a Vonage Developer account and take note of your API Key and Secret.

In your Directus project, create the following collections (and fields):

  • people - Primary Key Field: id (Generated UUID)
    • first_name (Type: String, Interface: Input)
    • last_name (Type: String, Interface: Input)
    • rsvp (Type: Boolean, Interface: Toggle)
    • requirements (Type: String, Interface: Input)
    • is_plus_one (Type: Boolean, Interface: Toggle)
  • invites - Primary Key Field: slug (Manually entered string)
    • message (Type: String, Interface: Input)
    • people (Type: Alias, Interface: One to Many)
      • Related Collection: people
      • Foreign Key: invite
      • Display Template First Name Last Name
    • plus_one_allowed (Type: Boolean, Interface: Toggle)
    • ceremony_invite (Type: Boolean, Interface: Toggle)
    • completed (Type: DateTime, Interface: Datetime)
    • email (Type: String, Interface: Input)
    • phone (Type: String, Interface: Input)

One invite will have multiple people, and optional characteristics (allowed +1, invited to ceremony). When guests respond, they will provide requirements (dietary/accessibility), and the person filling it in will provide an email and phone number. Finally, if given a +1 and it's used, a new person will be created and is_plus_one will be set to true.

Here's an illustration of an invite page, annotated to show where values in Directus will alter it.

A screenshot shows a mockup of the interface. A custom message is shown on each invite, a section with ceremony information is only shown if they are invited to that portion. A yes/no toggle is shown for each guest, and a +1 name and requirements section is only shown if the invite grants a +1

The most minimal invite (one guest, reception-only, no +1) looks like this:

A mockup showing a basic invite with one guest, no ceremony or +1 information or form

Create a new directory for this project and open it in a code editor. Create a .env file and populate it with your keys:

DIRECTUS_URL=your_project_url_here
DIRECTUS_TOKEN=your_admin_token_here
DIRECTUS_URL=your_project_url_here
DIRECTUS_TOKEN=your_admin_token_here

Run npx gitignore node to create a suitable .gitignore file for this project. Create a package.json file with npm init -y and then install our dependencies:

npm install dotenv express hbs @directus/sdk
npm install dotenv express hbs @directus/sdk

If you don't want to follow along, you can now skip to the bottom of this tutorial for the complete code.

Create an index.js file and open it in your code editor.

Set Up Application

Create and set up an Express.js application with the handlebars view engine:

js
import 'dotenv/config';
import express from 'express';
import { engine } from 'express-handlebars';

const app = express();
app.use(express.static('public'));
app.use(express.urlencoded({ extended: true }));

app.engine('handlebars', engine());
app.set('view engine', 'handlebars');
app.set('views', './views');

app.get('/invite/:slug', (req, res) => {
  res.render('invite', { 
    layout: false, 
    name: req.params.slug 
  });
});

app.listen(3000);
import 'dotenv/config';
import express from 'express';
import { engine } from 'express-handlebars';

const app = express();
app.use(express.static('public'));
app.use(express.urlencoded({ extended: true }));

app.engine('handlebars', engine());
app.set('view engine', 'handlebars');
app.set('views', './views');

app.get('/invite/:slug', (req, res) => {
  res.render('invite', { 
    layout: false, 
    name: req.params.slug 
  });
});

app.listen(3000);

Create a views directory and, inside of it, an invite.handlebars file:

html
<!DOCTYPE html>
<html>
<body>
   {{ name }}
</body>
</html>
<!DOCTYPE html>
<html>
<body>
   {{ name }}
</body>
</html>

Run your application with node index.js in the terminal, and navigate to localhost:3000/invite/hello. You should see a page with 'hello'.

Set Up Directus SDK

Import and initialize a new Directus client with rest features. Import the createItem, readItem, and updateItem functions for later.

js
import 'dotenv/config';
import express from 'express';
import { engine } from 'express-handlebars';

import { createDirectus, staticToken, rest, createItem, readItem, updateItem } from '@directus/sdk'; 
const directus = createDirectus(process.env.DIRECTUS_URL) 
  .with(rest()) 
  .with(staticToken(process.env.DIRECTUS_TOKEN)); 
import 'dotenv/config';
import express from 'express';
import { engine } from 'express-handlebars';

import { createDirectus, staticToken, rest, createItem, readItem, updateItem } from '@directus/sdk'; 
const directus = createDirectus(process.env.DIRECTUS_URL) 
  .with(rest()) 
  .with(staticToken(process.env.DIRECTUS_TOKEN)); 

Create Invite Pages

Update the invite route handler to utilize the Directus SDK and read the invite from your Directus project.

js
app.get('/invite/:slug', async (req, res) => {
  const data = await directus.request( 
    readItem('invites', req.params.slug, { 
      fields: ['*', { people: [ 'id', 'first_name', 'last_name' ] }] 
    }) 
  ); 

  res.render('invite', { 
    layout: false, 
    name: req.params.slug 
    ...data 
  }); 
});
app.get('/invite/:slug', async (req, res) => {
  const data = await directus.request( 
    readItem('invites', req.params.slug, { 
      fields: ['*', { people: [ 'id', 'first_name', 'last_name' ] }] 
    }) 
  ); 

  res.render('invite', { 
    layout: false, 
    name: req.params.slug 
    ...data 
  }); 
});

Update the content of the <body> tag to include values from the invite:

html
<body>
  <h1>You're invited!</h1>
  <p>{{ message }}</p>
  <div>
    {{#if ceremony_invite}}
      <p>Ceremony-specific information</p>
    {{/if}}
    <p>Reception-specific information</p>
  </div>
</body>
<body>
  <h1>You're invited!</h1>
  <p>{{ message }}</p>
  <div>
    {{#if ceremony_invite}}
      <p>Ceremony-specific information</p>
    {{/if}}
    <p>Reception-specific information</p>
  </div>
</body>

Show everyone the personalized message as part of their invite and the reception details. Only a subset of guests are invited to the ceremony as denoted by cermeony_invite being set to true.

Create five items in the people collection with just a name, and four invites to test:

  1. ceremony_invite = false, plus_one_allowed = false, slug = 'a'
  2. ceremony_invite = true, plus_one_allowed = false, slug = 'b'
  3. ceremony_invite = false, plus_one_allowed = true, slug = 'c'
  4. ceremony_invite = true, plus_one_allowed = true, slug = 'd' (assign two people to this invite)

This should give all key permutations to test against. Restart your server, navigate to localhost:3000/invite/a and you should see only the reception information. When you go to localhost:3000/invite/b you should see ceremony information as well.

Build RSVP Form

Underneath the existing <div>, add a conditional that will only show the form is the invite has not yet been RSVP'd to:

html
{{#if completed}}
  <p>Thanks for responding</p>
{{else}}
  <!-- Further code goes here -->
{{/if}}
{{#if completed}}
  <p>Thanks for responding</p>
{{else}}
  <!-- Further code goes here -->
{{/if}}

In the {{else}} block, create a form:

html
<form action="/rsvp" method="POST">

  {{#each people}}
    <div style="border: 1px solid black;">
      <h2>{{ this.first_name }} {{ this.last_name }}</h2>

      <input type="radio" name="rsvp-{{ this.id }}" value="false" id="rsvp-no-{{ this.id }}" required>
      <label for="rsvp-no-{{ this.id }}">Accept</label><br>
      
      <input type="radio" name="rsvp-{{ this.id }}" value="true" id="rsvp-yes-{{ this.id }}" required>
      <label for="rsvp-yes-{{ this.id }}">Regrets</label><br>

      <input type="text" name="requirements-{{ this.id }}" id="requirements-{{ this.id }}" placeholder="Requirements">
    </div><br>
  {{/each}}

  <p>Only one needed per invite</p>
  <input type="email" placeholder="Email" name="email" required>
  <input type="text" placeholder="Phone" name="phone" required>
  <input type="hidden" value="{{ slug }}" name="slug">
  <input type="submit">
</form>
<form action="/rsvp" method="POST">

  {{#each people}}
    <div style="border: 1px solid black;">
      <h2>{{ this.first_name }} {{ this.last_name }}</h2>

      <input type="radio" name="rsvp-{{ this.id }}" value="false" id="rsvp-no-{{ this.id }}" required>
      <label for="rsvp-no-{{ this.id }}">Accept</label><br>
      
      <input type="radio" name="rsvp-{{ this.id }}" value="true" id="rsvp-yes-{{ this.id }}" required>
      <label for="rsvp-yes-{{ this.id }}">Regrets</label><br>

      <input type="text" name="requirements-{{ this.id }}" id="requirements-{{ this.id }}" placeholder="Requirements">
    </div><br>
  {{/each}}

  <p>Only one needed per invite</p>
  <input type="email" placeholder="Email" name="email" required>
  <input type="text" placeholder="Phone" name="phone" required>
  <input type="hidden" value="{{ slug }}" name="slug">
  <input type="submit">
</form>

Let's break this down:

  1. The form will submit a POST request to /rsvp. This will be created later.
  2. For every person assigned to this invite, create a set of three inputs - a pair of radio buttons for RSVP yes/no, and one to optionally collect any accessibility or dietary requirements. Note that all inputs have a dynamic name which includes that person's ID from the database. For example, name="rsvp-{{ this.id }}" would become name="rsvp-ff028423-7111…"
  3. There is one email and phone number collected per RSVP.
  4. A hidden field contains the slug, so it is also submitted with the rest of the form.

Add +1 Form Fields

If the invite has plus_one_allowed set to true, show additional fields to collect their information:

html
{{/each}}

{{#if plus_one_given}} 
  <div style="border: 1px solid black;"> 
    <h2>Your +1</h2> 
    <input type="text" name="first-name-plus-one" id="first-name-plus-one" placeholder="First Name"> 
    <input type="text" name="last-name-plus-one" id="last-name-plus-one" placeholder="Last Name"> 
    <input type="text" name="requirements-plus-one" id="requirements-plus-one" placeholder="Requirements"> 
  </div> 
{{/if}} 

<p>Only one needed per invite</p>
{{/each}}

{{#if plus_one_given}} 
  <div style="border: 1px solid black;"> 
    <h2>Your +1</h2> 
    <input type="text" name="first-name-plus-one" id="first-name-plus-one" placeholder="First Name"> 
    <input type="text" name="last-name-plus-one" id="last-name-plus-one" placeholder="Last Name"> 
    <input type="text" name="requirements-plus-one" id="requirements-plus-one" placeholder="Requirements"> 
  </div> 
{{/if}} 

<p>Only one needed per invite</p>

Restart your server, navigate to localhost:3000/invite/b and you should not see the +1 fields. When you go to localhost:3000/invite/d you should. Here's what the form looks like when a +1 is allowed on the invite:

A screenshot shows the full invite page with no styling

It isn't winning any design awards, but it works.

Submit RSVP

Create a new route handler in index.js for the POST submission:

js
app.post('/rsvp', async (req, res) => {
  console.log(req.body);
});
app.post('/rsvp', async (req, res) => {
  console.log(req.body);
});

Restart the server, fill and submit the form. The terminal should look something like this:

A terminal shows a logged object with 7 properties. 2 of them start rsvp and then a UUID. 2 of them start requirements and end with the same UUIDs.

Update People With Their RSVP

To get a list of only people IDs, filter this object to an array of just the properties starting with rsvp, and then remove rsvp- from the start of the strings. Under the console.log:

js
const rsvps = Object.keys(req.body)
  .filter(k => k.includes('rsvp'))
  .map(k => k.split('rsvp-')[1]);

// Then, loop through these IDs and update each person with their RSVP and requirements:

for(let rsvp of rsvps) {
  await directus.request(
    updateItem('people', rsvp, { 
      rsvp: req.body[`rsvp-${rsvp}`],
      requirements: req.body[`requirements-${rsvp}`]
    })
  );
}
const rsvps = Object.keys(req.body)
  .filter(k => k.includes('rsvp'))
  .map(k => k.split('rsvp-')[1]);

// Then, loop through these IDs and update each person with their RSVP and requirements:

for(let rsvp of rsvps) {
  await directus.request(
    updateItem('people', rsvp, { 
      rsvp: req.body[`rsvp-${rsvp}`],
      requirements: req.body[`requirements-${rsvp}`]
    })
  );
}

Create Person Item For +1

If the +1 form was filled in, create a new person and add their ID to the RSVPs list. Under the for loop add the following:

js
if(req.body['first-name-plus-one']) {
  const { id } = await directus.request(
  createItem('people', {
    first_name: req.body['first-name-plus-one'],
    last_name: req.body['last-name-plus-one'],
    requirements: req.body['requirements-plus-one'],
    is_plus_one: true,
    rsvp: true
   })
  );
  rsvps.push(id)
}
if(req.body['first-name-plus-one']) {
  const { id } = await directus.request(
  createItem('people', {
    first_name: req.body['first-name-plus-one'],
    last_name: req.body['last-name-plus-one'],
    requirements: req.body['requirements-plus-one'],
    is_plus_one: true,
    rsvp: true
   })
  );
  rsvps.push(id)
}

Update Invite

Each invite contains a phone number and email address of the lead contact. Under the for loop, update the invite:

js
await directus.request(
  updateItem('invites', req.body.slug, { 
    email: req.body.email,
    phone: req.body.phone,
    people: rsvps,
    completed: new Date()
  })
);
await directus.request(
  updateItem('invites', req.body.slug, { 
    email: req.body.email,
    phone: req.body.phone,
    people: rsvps,
    completed: new Date()
  })
);

If there was a +1, the new person will be added to the invite. The value of completed will also be set to the current Datetime. In Directus, you'll easily be able to see when an RSVP was made. In the application, this will replace the form with the message "Thanks for responding".

As the form will not appear when completed has a value, redirect back to the same page and it will appear as if the form has been replaced with the thank you message:

js
res.redirect(`/invite/${req.body.slug}`);
res.redirect(`/invite/${req.body.slug}`);

Standardize Phone Numbers

I can tell you from past experience - no one ever fills in their phone number consistently, and I wanted to build a batch update system so we could send people important messages as the day approaches.

To format numbers, I used the Vonage Number Insight API. In your Directus Project Settings create a new Flow.

Use an non-blocking Event Hook Trigger so the item is added to the collection and then the flow is run. Keep the scope to only the items.update on the Invites collection.

Add a new Request URL operation and set the URL to https://api.nexmo.com/ni/basic/json?api_key=VONAGE_API_KEY&api_secret=VONAGE_API_SECRET&number={{$trigger.payload.phone}}, being sure to change the values for your key and secret.

Once the request has been successful, add an Update Data operation on the Invites collection. Set the payload to the following:

js
{
    "phone": "{{$last.data.international_format_number}}"
}
{
    "phone": "{{$last.data.international_format_number}}"
}

Save the Flow and test it by submitting a new invite RSVP. You may need to manually set an invite's Completed value to null to see the form again. The whole flow should look like this:

A flow has one trigger and two operations. The trigger is an actio nevent hook triggered when an item in the invites collection is updated. The first operation is a Request URL to the Vonage API. The final operation updates the invites item.

See Guest List

To see a running guest-list, navigate to the People collection in the content module and add a filter RSVP = true. You can see just the ceremony list by adding the filter Invite -> Ceremony Invite = true.

Summary & Next Steps

In this tutorial, you have created a wedding invite and RSVP system backed by a Directus database, and then standardized provided data using the Vonage Number Insight API.

Now that you have standardized data, you may choose to send batch messages via SMS or email using Flows.

If you have any questions or feedback, feel free to join our Discord server.

Complete Code

index.js

js
import 'dotenv/config';
import express from 'express';
import { engine } from 'express-handlebars';
import { createDirectus, staticToken, rest, createItem, readItem, updateItem } from '@directus/sdk';
const directus = createDirectus(process.env.DIRECTUS_URL)
  .with(rest())
  .with(staticToken(process.env.DIRECTUS_TOKEN));

const app = express();
app.use(express.static('public'));
app.use(express.urlencoded({ extended: true }));

app.engine('handlebars', engine());
app.set('view engine', 'handlebars');
app.set('views', './views');

app.get('/invite/:slug', async (req, res) => {
  const data = await directus.request(
    readItem('invites', req.params.slug, { 
      fields: ['*', { people: [ 'id', 'first_name', 'last_name' ] }] 
    })
  );
  res.render('invite', { layout: false, ...data });
});

app.post('/rsvp', async (req, res) => {
  console.log(req.body)
  // Get list of RSVP IDs
  const rsvps = Object.keys(req.body)
    .filter(k => k.includes('rsvp'))
    .map(k => k.split('rsvp-')[1]);

  // Update people with RSVPs
  for(let rsvp of rsvps) {
    await directus.request(
      updateItem('people', rsvp, { 
        rsvp: req.body[`rsvp-${rsvp}`],
        requirements: req.body[`requirements-${rsvp}`]
      })
    );
  }

  // Add +1 if exists
  if(req.body['first-name-plus-one']) {
    const { id } = await directus.request(
      createItem('people', {
        first_name: req.body['first-name-plus-one'],
        last_name: req.body['last-name-plus-one'],
        requirements: req.body['requirements-plus-one'],
        is_plus_one: true,
        rsvp: true
      })
    );
    rsvps.push(id);
  }

  // Update invite
  await directus.request(
    updateItem('invites', req.body.slug, { 
      email: req.body.email,
      phone: req.body.phone,
      people: rsvps,
      completed: new Date()
    })
  );

  res.redirect(`/invite/${req.body.slug}`);
});

app.listen(3000);
import 'dotenv/config';
import express from 'express';
import { engine } from 'express-handlebars';
import { createDirectus, staticToken, rest, createItem, readItem, updateItem } from '@directus/sdk';
const directus = createDirectus(process.env.DIRECTUS_URL)
  .with(rest())
  .with(staticToken(process.env.DIRECTUS_TOKEN));

const app = express();
app.use(express.static('public'));
app.use(express.urlencoded({ extended: true }));

app.engine('handlebars', engine());
app.set('view engine', 'handlebars');
app.set('views', './views');

app.get('/invite/:slug', async (req, res) => {
  const data = await directus.request(
    readItem('invites', req.params.slug, { 
      fields: ['*', { people: [ 'id', 'first_name', 'last_name' ] }] 
    })
  );
  res.render('invite', { layout: false, ...data });
});

app.post('/rsvp', async (req, res) => {
  console.log(req.body)
  // Get list of RSVP IDs
  const rsvps = Object.keys(req.body)
    .filter(k => k.includes('rsvp'))
    .map(k => k.split('rsvp-')[1]);

  // Update people with RSVPs
  for(let rsvp of rsvps) {
    await directus.request(
      updateItem('people', rsvp, { 
        rsvp: req.body[`rsvp-${rsvp}`],
        requirements: req.body[`requirements-${rsvp}`]
      })
    );
  }

  // Add +1 if exists
  if(req.body['first-name-plus-one']) {
    const { id } = await directus.request(
      createItem('people', {
        first_name: req.body['first-name-plus-one'],
        last_name: req.body['last-name-plus-one'],
        requirements: req.body['requirements-plus-one'],
        is_plus_one: true,
        rsvp: true
      })
    );
    rsvps.push(id);
  }

  // Update invite
  await directus.request(
    updateItem('invites', req.body.slug, { 
      email: req.body.email,
      phone: req.body.phone,
      people: rsvps,
      completed: new Date()
    })
  );

  res.redirect(`/invite/${req.body.slug}`);
});

app.listen(3000);

views/invite.handlebars

html
<!DOCTYPE html>
<html>
<body>
  <h1>You're invited!</h1>
  <p>{{ message }}</p>
  <div>
    {{#if ceremony_invite}}
      <p>Ceremony-specific information</p>
    {{/if}}
    <p>Reception-specific information</p>
  </div>
  {{#if completed}}
    <p>Thanks for responding</p>
  {{else}}
    <form action="/rsvp" method="POST">
      {{#each people}}
        <div style="border: 1px solid black;">
          <h2>{{ this.first_name }} {{ this.last_name }}</h2>

          <input type="radio" name="rsvp-{{ this.id }}" value="false" id="rsvp-no-{{ this.id }}" required>
          <label for="rsvp-no-{{ this.id }}">Accept</label><br>
          
          <input type="radio" name="rsvp-{{ this.id }}" value="true" id="rsvp-yes-{{ this.id }}" required>
          <label for="rsvp-yes-{{ this.id }}">Regrets</label><br>

          <input type="text" name="requirements-{{ this.id }}" id="requirements-{{ this.id }}" placeholder="Requirements">
        </div>
        <br>
      {{/each}}
      {{#if plus_one_allowed}}
      <div style="border: 1px solid black;">
        <h2>Your +1</h2>
        <input type="text" name="first-name-plus-one" id="first-name-plus-one" placeholder="First Name">
        <input type="text" name="last-name-plus-one" id="last-name-plus-one" placeholder="Last Name">
        <input type="text" name="requirements-plus-one" id="requirements-plus-one" placeholder="Requirements">
      </div>
      {{/if}}
      <p>Only one needed per invite</p>
      <input type="email" placeholder="Email" name="email" required>
      <input type="text" placeholder="Phone" name="phone" required>
      <input type="hidden" value="{{ slug }}" name="slug">
      <input type="submit">
    </form>
  {{/if}}
</body>
</html>
<!DOCTYPE html>
<html>
<body>
  <h1>You're invited!</h1>
  <p>{{ message }}</p>
  <div>
    {{#if ceremony_invite}}
      <p>Ceremony-specific information</p>
    {{/if}}
    <p>Reception-specific information</p>
  </div>
  {{#if completed}}
    <p>Thanks for responding</p>
  {{else}}
    <form action="/rsvp" method="POST">
      {{#each people}}
        <div style="border: 1px solid black;">
          <h2>{{ this.first_name }} {{ this.last_name }}</h2>

          <input type="radio" name="rsvp-{{ this.id }}" value="false" id="rsvp-no-{{ this.id }}" required>
          <label for="rsvp-no-{{ this.id }}">Accept</label><br>
          
          <input type="radio" name="rsvp-{{ this.id }}" value="true" id="rsvp-yes-{{ this.id }}" required>
          <label for="rsvp-yes-{{ this.id }}">Regrets</label><br>

          <input type="text" name="requirements-{{ this.id }}" id="requirements-{{ this.id }}" placeholder="Requirements">
        </div>
        <br>
      {{/each}}
      {{#if plus_one_allowed}}
      <div style="border: 1px solid black;">
        <h2>Your +1</h2>
        <input type="text" name="first-name-plus-one" id="first-name-plus-one" placeholder="First Name">
        <input type="text" name="last-name-plus-one" id="last-name-plus-one" placeholder="Last Name">
        <input type="text" name="requirements-plus-one" id="requirements-plus-one" placeholder="Requirements">
      </div>
      {{/if}}
      <p>Only one needed per invite</p>
      <input type="email" placeholder="Email" name="email" required>
      <input type="text" placeholder="Phone" name="phone" required>
      <input type="hidden" value="{{ slug }}" name="slug">
      <input type="submit">
    </form>
  {{/if}}
</body>
</html>

What do you think?

How helpful was this article?