Building a Wedding Invite System with Node.js, Vonage, and Directus
Published August 11th, 2023
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.
The most minimal invite (one guest, reception-only, no +1) looks like this:
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:
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:
<!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.
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.
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:
<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:
ceremony_invite
=false
,plus_one_allowed
=false
,slug
= 'a'ceremony_invite
=true
,plus_one_allowed
=false
,slug
= 'b'ceremony_invite
=false
,plus_one_allowed
=true
,slug
= 'c'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:
{{#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:
<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:
- The form will submit a POST request to
/rsvp
. This will be created later. - 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 becomename="rsvp-ff028423-7111…"
- There is one email and phone number collected per RSVP.
- 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:
{{/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:
It isn't winning any design awards, but it works.
Submit RSVP
Create a new route handler in index.js
for the POST submission:
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:
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
:
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:
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:
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:
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:
{
"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:
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
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
<!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>