Guide · Embedding

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

Last updated 7 min read5 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. Microsoft's 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 Microsoft's 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#

Microsoft's default 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 Microsoft's 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 Microsoft's 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. Microsoft's FAQ says each service has its own URL under Service booking page in the service's 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 /answers/microsoft-bookings-direct-link-to-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 Microsoft's 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's 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. Microsoft's booking 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. Microsoft's page has no setting to send visitors to your URL after they book (see /answers/microsoft-bookings-thank-you-page-redirect).

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 /guides/track-microsoft-bookings-ga4-gtm, and the iframe limits are covered in /answers/microsoft-bookings-gtm-iframe-tracking.

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 Microsoft's 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 /answers/microsoft-bookings-iframe-refused-to-connect.

Cut off or double scrollbars. Increase height or mobileHeight. The frame can't report its own content height. See /answers/microsoft-bookings-iframe-height-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. Microsoft's page doesn't accept prefill parameters (/answers/microsoft-bookings-prefill-form-url).

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

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

Doing this with BookingsXP#

If you need your app to know when a booking happens, the @bookingsxp/* packages render a booking widget for the same shared booking page. They resize to their content and emit events, including a completed-booking callback. There's no Microsoft sign-in, app registration or admin consent, and the booking is still created in Microsoft Bookings. BookingsXP is independent and not affiliated with or endorsed by Microsoft.

React and Next.js. @bookingsxp/react is marked "use client". Callbacks are functions, so pass them from a client component:

TSX
"use client";

import { BookingsXPWidget } from "@bookingsxp/react";
import { useRouter } from "next/navigation";

export function BookingWidget() {
  const router = useRouter();
  return (
    <BookingsXPWidget
      widget="w_8fk2m1qz"
      onBooked={(e) => router.push(`/thank-you?ref=${encodeURIComponent(e.booking?.id ?? "")}`)}
    />
  );
}

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

Vue 3 emits booked:

Vue
<script setup lang="ts">
import { BookingsXPWidget, type WidgetEvent } from "@bookingsxp/vue";

function onBooked(e: WidgetEvent) {
  console.log("Booked", e.booking?.id);
}
</script>

<template>
  <BookingsXPWidget widget="w_8fk2m1qz" @booked="onBooked" />
</template>

Svelte 5 takes an onbooked prop:

Svelte
<script lang="ts">
  import BookingsXPWidget from "@bookingsxp/svelte";
  import type { WidgetEvent } from "@bookingsxp/svelte";

  function onbooked(e: WidgetEvent) {
    console.log("Booked", e.booking?.id);
  }
</script>

<BookingsXPWidget widget="w_8fk2m1qz" {onbooked} />

Astro renders on the server and ships no framework JavaScript. Listen for the DOM event in a script:

Astro
---
import BookingsXPWidget from "@bookingsxp/astro/BookingsXPWidget.astro";
---

<BookingsXPWidget widget="w_8fk2m1qz" />

<script>
  window.addEventListener("bookingsxp:booking_completed", (e) => {
    console.log("Booked", (e as CustomEvent).detail.booking?.id);
  });
</script>

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. See /features/embed and /features/analytics.

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