Guide · Embedding

Microsoft Bookings in React, Next.js, Vue, Svelte and Astro

Last updated 6 min readPart of Microsoft Bookings on your website: every embed option5 sources

This guide is for developers adding a Microsoft Bookings shared booking page to a React, Next.js, Vue, Svelte or Astro site. You'll build a small, accessible iframe component with responsive sizing, see why your app can't react to bookings made inside it, and get a component-based alternative that can. The native component takes about 15 minutes. Most of that is testing heights.

Prerequisites#

  • A shared booking page that the public can book. Microsoft offers embed code for shared pages only, not "Bookings with me" pages.
  • The page URL, in the form https://outlook.office.com/book/YourBusiness@contoso.com/. In Bookings, pick the page in the left pane and open Booking page. The share options include Embed in Your Website, which copies the link and the iframe.
  • On Booking page → Access control, "Require a Microsoft 365 account from my organization to book" turned off. With it on, the frame redirects to the Microsoft sign-in page, which refuses to be framed.

Keep the URL in configuration, for example NEXT_PUBLIC_BOOKINGS_URL or PUBLIC_BOOKINGS_URL, so staging and production can point at different pages.

The shared CSS#

The default Microsoft snippet uses height='100%', which collapses to almost nothing inside a normal layout. The frame needs an explicit height tall enough for the longest step (usually the details form), and a taller one on narrow screens where the Bookings layout stacks. Put this in your global stylesheet, or in each component's scoped styles:

css
.bookings-embed {
  width: 100%;
  max-width: 1000px;
  margin-inline: auto;
}

.bookings-embed iframe {
  display: block;
  width: 100%;
  height: var(--bookings-height, 1150px);
  border: 0;
}

@media (max-width: 700px) {
  .bookings-embed iframe {
    height: var(--bookings-height-mobile, 1500px);
  }
}

The custom properties let each page override the heights without new CSS. Tune them by making a full test booking on a phone and on desktop.

React and Next.js#

TSX
import type { CSSProperties } from "react";

type BookingsEmbedProps = {
  url: string;
  title?: string;
  height?: number;
  mobileHeight?: number;
};

export function BookingsEmbed({
  url,
  title = "Book an appointment",
  height = 1150,
  mobileHeight = 1500,
}: BookingsEmbedProps) {
  const style = {
    "--bookings-height": `${height}px`,
    "--bookings-height-mobile": `${mobileHeight}px`,
  } as CSSProperties;

  return (
    <div className="bookings-embed" style={style}>
      <iframe src={url} title={title} loading="lazy" />
    </div>
  );
}

Notes:

  • title is required for accessibility. Screen readers announce it when focus enters the frame.
  • loading="lazy" stops the Bookings page loading until the visitor scrolls near it, which keeps it out of your initial page load. Drop it if the booking page is the first thing on screen.
  • In the Next.js App Router this component has no hooks or handlers, so it works as a Server Component. Use it directly in app/book/page.tsx:
TSX
import { BookingsEmbed } from "@/components/bookings-embed";

export default function BookPage() {
  return <BookingsEmbed url={process.env.NEXT_PUBLIC_BOOKINGS_URL ?? ""} />;
}

Vue 3#

Vue
<script setup lang="ts">
const props = withDefaults(
  defineProps<{ url: string; title?: string; height?: number; mobileHeight?: number }>(),
  { title: "Book an appointment", height: 1150, mobileHeight: 1500 },
);
</script>

<template>
  <div
    class="bookings-embed"
    :style="{
      '--bookings-height': `${props.height}px`,
      '--bookings-height-mobile': `${props.mobileHeight}px`,
    }"
  >
    <iframe :src="props.url" :title="props.title" loading="lazy"></iframe>
  </div>
</template>

This works unchanged in Nuxt, since an iframe renders fine on the server.

Svelte 5#

Svelte
<script lang="ts">
  let {
    url,
    title = "Book an appointment",
    height = 1150,
    mobileHeight = 1500,
  }: { url: string; title?: string; height?: number; mobileHeight?: number } = $props();
</script>

<div
  class="bookings-embed"
  style:--bookings-height="{height}px"
  style:--bookings-height-mobile="{mobileHeight}px"
>
  <iframe src={url} {title} loading="lazy"></iframe>
</div>

You can put the shared CSS in this component's style block instead of a global file. Svelte scopes it to this component, which is all it needs. In SvelteKit the component renders on the server as plain HTML.

Astro#

Astro
---
interface Props {
  url: string;
  title?: string;
  height?: number;
  mobileHeight?: number;
}

const { url, title = "Book an appointment", height = 1150, mobileHeight = 1500 } = Astro.props;
---

<div
  class="bookings-embed"
  style={`--bookings-height: ${height}px; --bookings-height-mobile: ${mobileHeight}px`}
>
  <iframe src={url} title={title} loading="lazy"></iframe>
</div>

Astro renders this to static HTML with no client JavaScript.

One service per page#

On a landing page for a single offer, skip the service list. The Bookings FAQ says each service has its own URL under Service booking page in the service details. Pass that URL as url and the frame opens on that service. Keep the full page link for your general "Book" page. The service link is shorter to fill in, so you can usually lower height too. See linking straight to one service.

Why you can't listen to events from the iframe#

Once the component works, the next request is usually "fire a conversion when someone books" or "show our own thank-you screen". With the Microsoft iframe you can't do either reliably.

  • Same-origin policy. The frame is served from outlook.office.com. Your code can't read iframe.contentWindow.document, attach listeners inside it or query its DOM. The browser throws a security error.
  • No messages. A cross-origin page can choose to talk to its parent with postMessage. Microsoft documents no such messages for Bookings, as of September 2026, and requests for a JavaScript embed on Microsoft forums have gone unanswered. Any message you happen to observe is undocumented and could change without notice. Don't build on it.
  • onLoad isn't a booking. The Bookings page is a single-page app, so the frame's load event fires when the page first loads and not when the visitor moves between steps or confirms.
  • No redirect. The Bookings page has no setting to send visitors to your URL after they book (see the thank-you page redirect answer).

What you can measure is on your side of the frame: clicks on buttons that open or scroll to the booking section, and whether the frame was visible. For example, push a visibility event once:

TSX
"use client";

import { useEffect, useRef } from "react";

declare global {
  interface Window {
    dataLayer?: Record<string, unknown>[];
  }
}

export function useBookingsVisible() {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          window.dataLayer = window.dataLayer ?? [];
          window.dataLayer.push({ event: "bookings_embed_viewed" });
          observer.disconnect();
        }
      },
      { threshold: 0.5 },
    );
    observer.observe(el);
    return () => observer.disconnect();
  }, []);

  return ref;
}

Attach the returned ref to a wrapper div around BookingsEmbed. That measures intent, not completed bookings. The GTM side is in the GA4 and GTM tracking guide, and the iframe limits are covered in why GTM can't see inside the Bookings iframe.

Testing#

  • Build and serve the production bundle, then load the page in a private window.
  • Make a full test booking on desktop and on a phone-sized viewport. Confirm it appears in the Bookings calendar and the staff member's Outlook, and that the Microsoft confirmation email arrives.
  • Check the accessibility tree: the frame should be announced with your title.

Common problems#

Blank frame or "refused to connect". The page requires sign-in. Turn off "Require a Microsoft 365 account from my organization to book" under Booking page → Access control. Also check that the URL uses the current /book/ format and not the pre-2023 /owa/calendar/.../bookings/ form. More in fixing "refused to connect".

Cut off or double scrollbars. Increase height or mobileHeight. The frame can't report its own content height. See fixing the iframe height on mobile.

Query strings break the page. Adding UTM tags or other parameters to the Bookings URL has caused "Bad Request" errors for users. Don't forward your page's query string into src without testing.

Prefilling name or email. The Bookings page doesn't accept prefill parameters (prefilling the Bookings form).

Blocked by your Content-Security-Policy. CSP headers can come from Next.js middleware or config, an Astro adapter, or your hosting platform. Add the Microsoft hosts to frame-src:

Text
Content-Security-Policy: frame-src 'self' https://outlook.office.com https://outlook.office365.com

Doing this with BookingsXP#

If your app needs to know when a booking happens, BookingsXP renders a booking widget for the same shared booking page. It resizes to its content and reports each step, including a completed booking, to your page. There's no Microsoft sign-in, app registration or admin consent, and the booking is still created in Microsoft Bookings.

As of September 2026 the @bookingsxp/react, /vue, /svelte and /astro packages are npm release pending, so don't npm install them yet. The script embed works in every framework today: one script tag plus a <bookingsxp-widget> element, which draws into its own shadow root so hydration never sees its children.

React and Next.js. Load the script with next/script and render into a div with the JavaScript API, which takes callbacks directly:

TSX
"use client";

import Script from "next/script";
import { useRef } from "react";
import { useRouter } from "next/navigation";

type BookedEvent = { booking?: { id: string } };
declare global {
  interface Window {
    BookingsXP?: {
      render: (el: Element, opts: { widget?: string; bookingUrl?: string; onBooked?: (e: BookedEvent) => void }) => unknown;
    };
  }
}

export function BookingWidget() {
  const ref = useRef<HTMLDivElement>(null);
  const router = useRouter();

  return (
    <>
      <div ref={ref} />
      <Script
        src="https://bookingsxp.com/embed/v1.js"
        onReady={() => {
          if (!ref.current || !window.BookingsXP) return;
          window.BookingsXP.render(ref.current, {
            widget: "w_8fk2m1qz",
            onBooked: (e) => router.push(`/thank-you?ref=${encodeURIComponent(e.booking?.id ?? "")}`),
          });
        }}
      />
    </>
  );
}

Without an account, pass bookingUrl: "https://outlook.office.com/book/YourBusiness@contoso.com/" instead of widget.

Vue, Svelte and Astro. Add the script tag once (in app.html, nuxt.config, index.html or your Astro layout) and write the element in your template:

HTML
<bookingsxp-widget widget="w_8fk2m1qz"></bookingsxp-widget>

In Vue, tell the compiler it's a custom element (isCustomElement: (tag) => tag === "bookingsxp-widget" in the Vue plugin options) to avoid a resolve warning. Svelte and Astro need nothing extra. To react to a booking, listen on the window:

JavaScript
window.addEventListener("bookingsxp:booking_completed", (e) => {
  console.log("Booked", e.detail.booking?.id);
});

Every step also goes to window.dataLayer (for example bookingsxp.booking_completed), so Google Tag Manager can use it without app code. If you send a CSP, allow script-src https://bookingsxp.com; frame-src https://bookingsxp.com. The framework docs show the component APIs the packages will have, and how the embed works covers modes and templates. BookingsXP is independent and not affiliated with or endorsed by Microsoft.

Questions people also ask

Sources

  1. Microsoft Learn: Share shared bookings page (opens in a new tab) · learn.microsoft.com
  2. Microsoft Learn: Bookings faq (opens in a new tab) · learn.microsoft.com
  3. Microsoft Learn: Customize booking page (opens in a new tab) · learn.microsoft.com
  4. Microsoft Tech Community: Microsoft bookings iframe representation (opens in a new tab) · techcommunity.microsoft.com
  5. Microsoft Tech Community: Microsoft bookings conversion tracking with gtm (opens in a new tab) · techcommunity.microsoft.com