All posts

Build and host your own scheduling page using Next.js and Google APIs

For some weird reason, I’ve been interested in a personal self-scheduling solution (like Calendly,, but one that's simple, free, and without branding or bloat.

So, I made my own Calendly alternative, and wanted to share with the world and walk through it.

The main page of the personal calendaring app

The current solution is intentionally lightweight and opinionated, but hopefully flexible enough should you want to extend it.

The site has the following bits of functionality:

  1. It displays a calendar of your available dates for meetings. By default, I’ve configured it to show 14 days of availability. This isn't your usual calendar; it's not designed for long-range bookings out of the box. It lists all dates inline without pagination by month, keeping things simple.
  2. It supports meetings by phone or Google Meet. If Meet is selected, conference details will be added automatically.
  3. It allows you to review and approve requests before they’re added to your As I mentioned, it's opinionated and designed for simple use cases for individuals in mind. 😁

Some technical notes:

Getting started

Clone the repo

Clone my repository to get started.

git clone

Don't forget to npm install

Set up Google API

You can skip this section if you already have (or know how to get) an Oauth Client with scopes and and a redirect URI of

  1. Visit and create a new project. Don't sweat the name, nobody will see it but you.
  2. Once your project is created, head to APIs & ServicesLibrary and search for Gmail API and Google Calendar API. Click Enable.
  3. Then go to APIs & ServicesOAuth consent screen. If you have a Workspace account, choose Internal. If you're using a Gmail/personal account, you’ll have to choose External.
  4. Enter an App Name (you can just use Meet Me - nobody but you will see this). Choose a user support email from the dropdown (nobody but you will see it), and input your email address once more at the bottom under Developer Contact Information. Save and continue.
  5. Don’t worry about entering anything on the next screen when it asks you for scopes, just click Save and Continue.
  6. If you chose External in step 3, add your own account as a test user. Save and continue.
  7. That should do it. Now head to APIs & ServicesCredentials. and click Create Credentials; pick OAuth Client ID from the list.
  8. Pick Web application from "Application Type" and don't worry about the name.
  9. Add to Authorized Redirect URIs (you'll see why in a bit).
  10. Click Create and take a note of the Client ID and Client Secret. You'll need them.

What we just did was create an OAuth client, which will allow us to programmatically access your calendar and email.

We're going to use Google's OAuth Playground as a way to store a refresh token that will allow us to access your calendar and email indefinitely.

Get your refresh token

You can skip this section if you’re able to obtain a refresh_token of your own. If you have an OAuth Client ID already but need to get a refresh token, you’ll need to go to your existing credential and add as a redirect URL.

  1. Visit
  2. Press the gear on the right, and check the box to "Use your own OAuth credentials" -- paste your Client ID and Secret from Step 9.
  3. Now scroll through the list on the left and click the triangle next to Gmail API v1 and click so it gets a check. Then scroll to Google Calendar API v3 and click to check it.
  4. Click Authorize APIs.
  5. Follow the prompts. If you made this an External project, you might get a scary looking screen saying Google hasn't reviewed your app yet. Don't worry about it; hit the Continue button (on the left!).
  6. You'll be taken back to the screen you were just on with an Authorization code populated in the text box. Click the Exchange authorization code for tokens button.
  7. When the screen changes to "Step 3", click on "Step 2" to take you back to the text box with your refresh token. Copy it.

At this point you should have your:

You need to be VERY careful with these values. Don't commit them to Github directly, or send them to anyone. This allows programmatic access to read and write email and calendar events.

Because we're using nodemailer to send emails using XOAUTH2, we need the entire Gmail scope. In a future version, I’ll work to reduce the privileges needed.

Configure .env.local

Open up the code in your favorite editor, and open the .env.template.local file.

I’ve also chosen to store sensitive-ish values in this file, so fill your email address (must match your Gmail address), your name, and your phone number (if you want).

Rename this file to .env.local. If you’re using Vercel, make sure to upload these to your environment variables.

I’ve made sure that the .gitignore file in my repo ignores .env.local but always give a double check to your commits to make sure you don’t accidentally commit your secrets.

If you accidentally share these secrets, you should:

  1. Immediately revoke token access. Visit and look for the name of your app. then click "Remove Access"

  2. Generate a new Web Client Secret. Go to and click click the pencil icon next to your Web Client. On the next screen, click "Add Secret" to add a new secret. Then trash the old one.

Configure ./config.ts

  1. Set the allowed meeting durations by creating an array of integers representing the number of minutes for each duration. For example, to allow 15, 30, and 60-minute meetings:
export const ALLOWED_DURATIONS = [15, 30, 60]\
  1. Define the default meeting duration, which will be used if no other duration is specified. In this case, we set it to 30 minutes:
export const DEFAULT_DURATION = 30
  1. Specify the calendar(s) to check for availability by creating an array of calendar identifiers. In this case, we only check the "primary" calendar, which is Google’s default:
export const CALENDARS_TO_CHECK = ["primary"]
  1. Set the padding between available slots by specifying the number of minutes as SLOT_PADDING. In essence, this will show the specified number of minutes before and after each appointment in your calendar as "booked" so you’re not back-to-back. By default, it’s 0:
export const SLOT_PADDING = 0
  1. Set your IANA timezone string that you work out of (or more specifically, that your availability, below, will be defined in). By default, it’s mine :)
export const OWNER_TIMEZONE = "America/Los_Angeles"
  1. Specify the times you’d like to take meetings each week by setting OWNER_AVAILABILITY. The object accepts a day of the week as a key (0 = Sunday, 6 = Saturday), and for each key, accepts an array of intervals, which contain start and end times as hours.

To illustrate the point a little better, the default values are set up to allow appointments from 9AM - 5PM, Monday-Friday, in the timezone if OWNER_TIMEZONE:

    start: {
      hour: 9,
    end: {
      hour: 17,

export const OWNER_AVAILABILITY: AvailabilitySlotsMap = {

You can also tweak options for formatting local dates and times, but the defaults should be fine and flexible enough.

Now, let's dig into the code!

Key components

Before diving into the actual flow, let’s go over some of the basic building blocks that do the heavy lifting:

lib/availability functions

This is where the actual work of determining your availability takes place. The main functions here are:


This uses your refresh token to obtain an access_token that we can use for Bearer authorization when communicating with Google’s APIs.

const params = new URLSearchParams({
  grant_type: "refresh_token",
  client_secret: process.env.GOOGLE_OAUTH_SECRET,
  refresh_token: process.env.GOOGLE_OAUTH_REFRESH,
  client_id: process.env.GOOGLE_OAUTH_CLIENT_ID,

const response = await fetch("", {
  method: "POST",
  headers: {
    "Content-Type": "application/x-www-form-urlencoded",
  body: params.toString(),
  cache: "no-cache",

The response will include an access_token if successful, which we’d ideally cache for use across all sessions (since it’s valid for 1 hour). However, I’m assuming that my limited popularity means infrequent site visits won’t send too many API requests.

I opted to write this myself instead of using googleapis, which is a much heavier library.


This function generates an array of DateTimeInterval objects representing potential time slots available for booking.

function getPotentialTimes({
}: {
  start: Day
  end: Day
  duration: number
  availabilitySlots: AvailabilitySlotsMap
}): DateTimeInterval[]

We use the OWNER_AVAILABILITY constant in ./config.ts to define when you’d theoretically accept appointments when you’re not busy and generate duration-sized slots from the start Day to end Day.


We use the freeBusy service of Google Calendar to get intervals where you’re busy. This doesn’t expose any information about the appointments themselves, so it’s a handy way to get the times we need to block from being booked.

async function getBusyTimes({ start, end }: DateTimeInterval)

We pass it a starting Date and ending Date. We use the CALENDARS_TO_CHECK array in config.ts to specify the calendars we should consider when blocking time. (By default, we use primary.)

We’ll get back a single array of intervals that will be removed from the potential slots returned by getPotentialTimes.


This function reconciles the potential time slots with periods marked as busy. If padding is specified, each busy period will be buffered by that number of minutes, helping you avoid back-to-back meetings.

export default function getAvailability({
  potential: potentialParam,
  padding = SLOT_PADDING,
}: {
  potential?: DateTimeInterval[]
  busy?: DateTimeInterval[]
  padding?: number
}): DateTimeInterval[]

The end result is an array of slots that (a) fall within your daily schedule configuration, and (b) aren’t booked.


When we’re ready to actually add the appointment that a user requests to your calendar, this function handles it.

async function createCalendarAppointment(props: AppointmentProps)

This function constructs and executes an API request to Google Calendar that invites the recipient and provides instructions based on whether the meeting is a phone call or a Google meet meeting.


We’ll use useReducer() to handle the application’s state, which consists of:

 StateType = {
  /** The earliest day we’ll offer appointments */
  start: Day
  /** The latest day we’ll offer appointments */
  end: Day
  /** The day the user selected (if made) */
  selectedDate?: Day
  /** The end user’s timezone string */
  timeZone: string
  /** The number of minutes being requested,
   *  must be one of the values in ALLOWED_DURATIONS
  duration: number
  /** Whether the booking modal is open or busy. */
  modal: ModalStatus
  /** The time slot the user selected (if made). */
  selectedTime?: DateTimeInterval

In addition to your typical state functionality, this also handles a few pieces of “magic”:

The main page (pages/index.tsx)

We use getServerSideProps to handle querying Google Calendar’s API.

This is where the two weeks of availability is set, you can change the starting day or ending day to suit your needs. For example, you might not want to accept same day appointments (add an offset of 1 to start), or may only offer slots for one week (change the offset of end to 7.)

This also helps reconstitute the State from the query parameters. We use zod to do some basic validation for passed duration, timeZone and selectedDate.

We pass these props to the page, along with the busy times returned from Google.

export async function getServerSideProps({ query }: GetServerSidePropsContext) {
  // ....

  return {
    props: {
      start: start.toString(),
      end: end.toString(),
      busy: mapDatesToStrings(busy),
      ...(timeZone && { timeZone }),
      ...(selectedDate && { selectedDate }),

We leave timeZone and selectedDate out of the response.

You’ll notice that the default Page component is wrapped with our provider:

export default withProvider(Page)

This will allow us, in AvailabilityContext.tsx, to parse the params we resolve into the State.

In our page’s render function, we use the duration and timeZone from the state—as well as your configured start and end days—to build out the calendar.

const potential = getPotentialTimes({
  start: startDay,
  end: endDay,
  availabilitySlots: OWNER_AVAILABILITY,

const offers = getAvailability({
  busy: mapStringsToDates(busy),

This allows us to change the UI to adapt to different durations as the user changes them in the session:

Changing durations before and after

The availability components

In ./components/availability, we have the main AvailabilityPicker.tsx component, that brings together:

The picker itself also takes the array of availability times returned from getAvailability and turns them into a map, keyed by the date of the week.

This handles cases where timezone difference causes the end-user’s availability offerings to be on a different day than your local timezone:

Changing timezones before and after

While we’re creating this map, we also will keep track of the maximum number of available slots in any one day, which allows us to show a visual availability summary (more on that later.)

Timezone and Duration input controls

We consume state and dispatch updates in these components directly.

To avoid showing end-users a huge list of IANA timezone strings, it uses @vvo/tzdb and only shows unique display strings.

In cases where the passed timeZone isn’t the canonical timeZone for an area (e.g. America/Kentucky/Louisville isn’t represented; but it’s the same as America/New_York), we make sure the right timezone is shown as selected in the dropdown.


This is my opinionated take on a calendar, with the assumption that you don’t want to schedule months out in advance:

Calendar.tsx will generate days from start to end. It will show greyed out and disabled days before start and after end so every row of weeks is complete (starts Sunday, and ends Saturday).

This won’t work for every single international context, so you may need to tweak it a bit if your weeks don’t start on Sunday and end on Saturday.

DayButton.tsx is used to render the dates and handles dispatching events to update the selectedDate state and show times that correspond to the date selected.



This functionality is a little more straightforward; we simply iterate over the times that are within the range of the selected date (in the requested timezone).

TimeButton.tsx renders a button that will dispatch an update to selectedSlot and open the booking modal.

The booking form

./components/booking/BookingForm.tsx uses Modal.tsx to render itself, presenting the user with a human-readable version of the time slot they selected in their local timezone.

There’s an array of locations that will render as option buttons to let the user choose how they’d like to meet. Right now, it’s just meet and phone. More on how those are used later.

We do only simple client-side validation here to make sure the email address is valid and name is provided.

We POST the result to ./pages/api/request and redirect the user to ./pages/confirmation.tsx once we get a successful response.

The request endpoint

./pages/api/request will take the name, email, location, start, end, duration and timeZone and use it to construct an email to OWNER_EMAIL from your .env file.

This email (which is rendered using ./lib/email/messages/Approval.ts) will give you the options to accept the meeting or decline the meeting.

Another email is sent to the user’s provided email, letting them know you’ll get back to them. This email is rendered using ./lib/email/messages/Confirmation.ts.

The URL to actually book the appointment gets constructed as /api/confirm?data={params}&key={hash}, where data is the serialized appointment information, and key is a sha256 hash of the data, salted with your GOOGLE_OAUTH_SECRET.

Also, I’ve added some lru-cache rate limiting. You might want to tweak this (or maybe add captcha protection, etc.) if you’re getting spam.

The confirm endpoint

This confirms (accepts) the meeting.

It validates that the data and hash match, and then uses the createAppointment helper function to issue the API request that will create the appointment and notify the recipient.

This helper function handles conditionally rendering instructions that include adding your OWNER_PHONE_NUMBER (from the .env file) as instructions for phone requests, or instructing Google to add meet conference details if requested.

Once you confirm the appointment, you’ll be taken to ./pages/booked?url={event_id} which will let you click into the actual Google Calendar permalink URL for the event that was just created.


Phew! That was a lot. This was a fun little project. There’s definitely more work to do, and I welcome any contributions!