Embedding a self-hosted Cal.diy Calendar in Next.js App Router - and not making it weird
Piotr Żarów
CEO at Dev and Deliver
2026-09-05
#Development
Time to read
10 mins
In this article
Introduction
The short version
Why Cal.diy, not Cal.com
Why '@calcom/embed-react', not a raw 'script' tag
The 'embedJsUrl' gotcha: Why your self-hosted embed loads cal.com
Why inline embed, not a popup
Which CSP directives a self-hosted Cal embed needs
The hero TerminalWindow
Contact section as a page anchor
Real pain points
Getting started
Page structure after Article 4
FAQ
Agency take
Share this article
Introduction
The booking calendar on a contact page is one of those features that looks trivial and isn't. It has an iframe, which means CSP. It has JavaScript loaded from a CDN, which means you need to control which CDN. It has lifecycle requirements, which means it fights React's component model if you pick the wrong integration approach. And if you're running a self-hosted instance instead of Cal.com's cloud, there's a non-obvious configuration step that the embed docs don't make prominent: calOrigin doesn't point the embed script at your server. You need a second prop for that. Miss it and your custom Cal instance loads calendar data from your server but runs embed JavaScript from cal.com's CDN - which means your self-hosted customisations are invisible to the embed layer.
This article covers Article 4 of the series: adding a Contact section to the Turborepo starter's landing page. That means a @calcom/embed-react inline embed wired to a self-hosted Cal.diy instance, a strict per-host CSP, and a TerminalWindow animation in the hero that communicates "monorepo with multiple apps" at a glance. All of it is in the open-source repo: https://github.com/DevAndDeliver/turborepo.
The short version
- Cal.diy, not Cal.com - self-hosted version of Cal.com. Consistent with the open-source/self-hosted theme of the series. Readers can run their own instance.
@calcom/embed-reactover a raw<script>tag - type-safe, works with App Router, nouseEffectboilerplate orwindowavailability guards.- Inline embed, not a popup - the popup approach threw
createIframe must be called before doInIframeintermittently. Root cause: Cal's popup script makes lifecycle assumptions about iframe initialisation that App Router's component model doesn't satisfy. Inline embed renders the iframe immediately on mount. Zero intermittent errors. embedJsUrlis required for self-hosted instances -calOrigintells the component where to fetch calendar data. It does not tell it where to loadembed.js. WithoutembedJsUrl, the embed script comes from cal.com's CDN regardless of yourcalOrigin.- CSP with no wildcards -
script-src,frame-src, andimg-srcare extended with the Cal host derived fromNEXT_PUBLIC_CAL_URLat build time. If the env var isn't set, the directives stay tight. Replacing your Cal instance is one env var change. - TerminalWindow in the hero - a Framer Motion stagger animation shows packages building in dependency order. Communicates the monorepo architecture without requiring the reader to look at a directory listing.
#contactas a real page section - indexable by search engines, shareable as a URL, works without JavaScript. Email address sits next to the calendar. Hero CTA and navbar both scroll to it.
Why Cal.diy, not Cal.com
The whole series is built around the "open source and self-hosted" angle. We use Contentful as the CMS (Article 3), we run our own calendar instance. Cal.diy is Cal.com's self-hosted distribution. You get the same product - same booking flows, same embed SDK - running on infrastructure you control.
For the starter, NEXT_PUBLIC_CAL_URL and NEXT_PUBLIC_CAL_LINK are the only two env vars readers need to change to point the embed at their own instance. The code doesn't hardcode cal.com anywhere.
Why '@calcom/embed-react', not a raw 'script' tag
The raw approach looks like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14useEffect(() => { if (typeof window === "undefined") return; const script = document.createElement("script"); script.src = "https://your-cal.example.com/embed/embed.js"; script.async = true; script.onload = () => { (window as any).Cal("init", { origin: "https://your-cal.example.com" }); (window as any).Cal("inline", { elementOrSelector: "#cal-embed", calLink: "your-username", }); }; document.body.appendChild(script); }, []);
That's five things to get right: typeof window guard, dynamic script injection, onload callback, global window.Cal, and cleanup on unmount. None of it is type-safe.
@calcom/embed-react wraps this into a single JSX component:
1 2 3 4 5 6 7 8 9import Cal from "@calcom/embed-react"; <Cal calLink={CAL_LINK} calOrigin={CAL_URL} embedJsUrl={`${CAL_URL}/embed/embed.js`} style={{ width: "100%", minHeight: "600px" }} config={{ layout: "month_view", theme: "dark" }} />
No useEffect. No window checks. The component handles script loading and initialisation internally. D33: @calcom/embed-react over raw script tag - type-safe, no SSR boilerplate, works with App Router.
The 'embedJsUrl' gotcha: Why your self-hosted embed loads cal.com
This is the one that isn't obvious from the docs.
calOrigin tells @calcom/embed-react where your Cal instance lives - it sets the origin for the data requests (availability, booking slots, confirmation). What it does not do is change where the component loads embed.js from.
By default, the component loads embed.js from app.cal.com:https://app.cal.com/embed/embed.js
If you only pass calOrigin:
1<Cal calLink="your-username" calOrigin="https://your-cal-instance.com" />
your self-hosted instance serves the calendar data, but the embed JavaScript comes from Cal.com's CDN. For most users this appears to work - the calendar renders. But any customisations in your self-hosted Cal instance that live in embed.js are invisible because you're running Cal.com's version of it, not yours.
The fix is one more prop:
1embedJsUrl={`${CAL_URL}/embed/embed.js`}
D37: embedJsUrl must be set explicitly for self-hosted instances. calOrigin is not enough.
The full Contact component:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39"use client"; import Cal from "@calcom/embed-react"; const CAL_URL = process.env.NEXT_PUBLIC_CAL_URL ?? "https://cal.com"; const CAL_LINK = process.env.NEXT_PUBLIC_CAL_LINK ?? "your-username"; export function Contact() { return ( <section id="contact" className="px-6 md:px-12 lg:px-20 py-24 md:py-32"> <div className="max-w-5xl"> <h2 className="font-mono font-bold text-zinc-50 tracking-tighter leading-none mb-4" style={{ fontSize: "clamp(2rem, 4vw, 3.5rem)" }} > Let's talk. </h2> <p className="text-zinc-400 font-light leading-relaxed max-w-[48ch]"> Book a call directly or reach out by email.{" "} <a href="mailto:hi@devanddeliver.com" className="text-zinc-200 underline underline-offset-2 hover:text-emerald-400 transition-colors duration-200" > hi@devanddeliver.com </a> </p> <div className="rounded-xl overflow-hidden ring-1 ring-white/8 bg-zinc-900/50"> <Cal calLink={CAL_LINK} calOrigin={CAL_URL} embedJsUrl={`${CAL_URL}/embed/embed.js`} style={{ width: "100%", minHeight: "600px" }} config={{ layout: "month_view", theme: "dark" }} /> </div> </div> </section> ); }
Why inline embed, not a popup
The first attempt used Cal's popup approach: a CTA button that calls Cal.ns.popup() to open the booking modal. It looked clean - a single "Book a call" button, modal opens over the page.
It threw this error intermittently: createIframe must be called before doInIframe
The error appeared on the first CTA click after a page load, disappeared on reload, came back unpredictably. The root cause: Cal's popup script expects a specific iframe initialisation sequence. Next.js App Router's component lifecycle - particularly around useEffect timing and React 19's concurrent rendering - doesn't match what Cal's popup code expects. The script initialises fine, but when the popup is triggered for the first time, the internal iframe isn't in the state the popup handler expects.
Debugging intermittent iframe lifecycle errors is a reliable way to spend an afternoon. We didn't want the article to be about debugging Cal internals.
The inline embed has none of this. <Cal /> renders the iframe immediately on mount. There's no popup trigger, no deferred initialisation, no timing-sensitive sequence. The calendar is on the page when the component mounts. D34: inline embed in the Contact section, not popup.
Which CSP directives a self-hosted Cal embed needs
The starter already had security headers in next.config.ts. Embedding a Cal iframe requires three directives: script-src (embed.js), frame-src (the iframe origin), and img-src (avatars, event type images).
We didn't want to add wildcards. The CSP derives the Cal host from NEXT_PUBLIC_CAL_URL at build time:
1 2 3 4 5 6 7 8 9 10 11 12 13// next.config.ts const calHost = (() => { try { return process.env.NEXT_PUBLIC_CAL_URL ? new URL(process.env.NEXT_PUBLIC_CAL_URL).host : ""; } catch { return ""; } })(); const calDirective = (base: string) => calHost ? `${base} https://${calHost}` : base;
Then in the headers:
1 2 3calDirective("script-src 'self' 'unsafe-inline' 'unsafe-eval'"), calDirective("frame-src 'self'"), calDirective("img-src 'self' data:"),
calDirective appends https://${calHost} to the base directive if a host is configured. If NEXT_PUBLIC_CAL_URL is unset or unparseable, the directives are unchanged. Changing your Cal instance is one env var update - the CSP follows automatically. D38: no wildcards, no *, tight per-host.
A note on frame-src: browsers block iframes whose origin isn't in frame-src. The failure mode is a blank white rectangle with no console error. The error appears in DevTools under the Security tab, not the Console tab. We spent longer than we'd like to admit looking at the wrong DevTools panel.
The hero TerminalWindow
The landing page hero had copy on the left and nothing on the right. The two-column layout needed something that communicated "monorepo with multiple apps" without requiring readers to parse a directory listing or a code snippet.
A simulated terminal running pnpm dev does this. Packages appear in the order Turborepo actually builds them - types first, then ui, then api and web in parallel. That's the real build graph. Readers who know Turborepo recognise it immediately.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16const packages = [ { name: "@repo/types", label: "types:dev", status: "compiled", time: "0.1s", port: null }, { name: "@repo/ui", label: "ui:dev", status: "compiled", time: "0.2s", port: null }, { name: "@repo/api", label: "api:dev", status: "ready", time: "1.2s", port: "3001" }, { name: "@repo/web", label: "web:dev", status: "ready", time: "0.7s", port: "3000" }, ]; const container = { hidden: {}, show: { transition: { staggerChildren: 0.15, delayChildren: 0.6 } }, }; const row = { hidden: { opacity: 0, x: -10 }, show: { opacity: 1, x: 0, transition: { duration: 0.35, ease: [0.32, 0.72, 0, 1] } }, };
Each package row slides in 150ms after the previous. The whole animation begins 600ms after mount - enough delay that the hero copy has rendered and the reader has a moment to read it before the terminal starts building. Framer Motion was already in the bundle (the waitlist form uses it), so this adds zero weight. D42: TerminalWindow - stagger animation communicates monorepo story.
One important detail on the outer motion.div: it starts with initial={{ opacity: 0, y: 24 }}. Without this, the terminal window is visible on the initial server render and snaps into position when the client hydrates. The initial prop ensures the window is hidden before the animation plays.
Contact section as a page anchor
The other option was a modal: clicking "Contact us" in the navbar opens a booking overlay. We didn't do this. D44:
#contactis a real page section. It's in the DOM. Search engines index it. Someone can sharehttps://yoursite.com/#contactand the recipient lands directly on the booking calendar.- It works without JavaScript. The email address and the iframe are plain HTML. If Cal's script fails to load, the email is still there.
- The email address sits next to the calendar. Someone who prefers email over booking can see both options without navigating anywhere.
- No popup lifecycle to debug.
The hero CTA and the "Contact us" navbar link both use <a href="#contact">. No router navigation, no useRouter, no scroll event handlers. Native anchor scroll with scroll-behavior: smooth in global CSS.
Real pain points
1. The popup lifecycle crash
createIframe must be called before doInIframe. Intermittent, first click only, disappears on reload. The popup approach assumes iframe initialisation happens before the trigger fires. App Router's component lifecycle doesn't guarantee this in the same way the Cal popup script expects. We tried calling Cal("init") in a useEffect, adding a ref check before the popup call, and adding a setTimeout delay. None of it reliably fixed the timing.
The root cause is that debugging Cal internals requires reading Cal's minified embed.js, which is not a pleasant afternoon. The fix - switching to inline embed - took five minutes. We made the pop-up approach work for exactly one session before deciding the inline embed was the correct answer.
2. 'calOrigin' silently not overriding the script URL
The calendar rendered. Availability loaded. Booking worked. We only discovered the script was coming from cal.com when we added a customisation to the self-hosted instance and it didn't appear. Checking DevTools Network tab showed embed.js loading from app.cal.com despite calOrigin pointing at our server.
The fix is documented above. The part that costs time is that nothing is obviously broken until you have a self-hosted customisation that differs from cal.com's default. For most readers of the @calcom/embed-react docs, this scenario doesn't arise - they're using cal.com and the default CDN is correct. For self-hosted instances, it's a silent failure until you know to look for it.
3. Why a blocked iframe shows a blank rectangle and no console error
The calendar rendered as a blank white rectangle. No error in the Console tab. Checking the Security tab in DevTools showed the iframe blocked by CSP: frame-src didn't include the Cal host. We'd added the Cal host to script-src and img-src but missed frame-src on the first pass.
The blank rectangle with no console error is worth remembering. Any time an iframe renders nothing, check frame-src first.
4. 'NEXT_PUBLIC_*' values baked in at build time
Contact.tsx is a "use client" component. It reads process.env.NEXT_PUBLIC_CAL_URL. In client components, Next.js replaces NEXT_PUBLIC_* references with their literal values at build time. If you build without the env var set, the component gets undefined. calOrigin is undefined. The embed silently falls back to cal.com, which is the ?? "https://cal.com" fallback we wrote explicitly.
This is expected Next.js behaviour, but it means: set NEXT_PUBLIC_CAL_URL in your .env.local before running pnpm dev, and set it in your deployment environment before the first production build. Set it after the build and the built bundle doesn't know about it. Article 5 covers this in the Vercel deployment context.
5. Framer Motion stagger and the initial 'opacity: 0'
The TerminalWindow stagger looked wrong on first load. Package rows appeared fully visible, then vanished, then animated in. The outer motion.div was missing initial={{ opacity: 0, y: 24 }}. Without it, the server-rendered HTML includes the terminal at full opacity. The client hydrates, Framer Motion takes control, and for a brief moment it reverts to opacity: 0 to start the animation - producing a visible flash.
initial={{ opacity: 0, y: 24 }} on the outer wrapper ensures the element starts hidden. The server render is suppressed for this element (Framer Motion handles this), so there's no flash.
Getting started
Add the package:
1pnpm --filter @repo/web add @calcom/embed-react
Set env vars in apps/web/.env.local:
1 2NEXT_PUBLIC_CAL_URL=https://your-cal-instance.com NEXT_PUBLIC_CAL_LINK=your-cal-username
For a local cal.com account, set NEXT_PUBLIC_CAL_URL=https://cal.com. The embed falls back to this value anyway - it's just cleaner to be explicit.
The .env.example in the repo has both vars documented with placeholder values.
Page structure after Article 4
1 2 3 4 5 6 7 8Navbar (Contact us → #contact | Subscribe → #subscribe) Hero (headline + TerminalWindow | CTA → #contact) Features TechStack Articles (from Contentful - Article 3) WaitlistForm (#subscribe - newsletter sign-up) Contact (#contact - email + inline Cal.diy iframe) Footer
No modal. No popup. Every section is a real page element with an anchor. The navbar links are <a> tags. The CTA is an <a> tag. There is no JavaScript routing between sections of the same page.
FAQ
Cal.diy or Cal.com?
Cal.diy is the self-hosted version. For a series built around open-source and self-hosting, running your own instance is consistent - and readers can run the same thing.
Why '@calcom/embed-react' rather than a raw 'script' tag?
The React wrapper handles the embed lifecycle for you. The raw script approach works, but you own the mounting and teardown.
Why does my self-hosted Cal embed still load scripts from cal.com?
Because calOrigin alone doesn't override the script URL - you also need embedJsUrl. The calendar renders and booking works, so this goes unnoticed until you add a customisation that never applies.
Why is my Cal iframe a blank white rectangle with no console error?
CSP is blocking it via frame-src, and blocked frames don't log to the Console tab. Check the Security tab in DevTools. The embed needs three directives: script-src, frame-src and img-src.
What format does 'calLink' take?
username/event-type, not a bare username. A bare username is a profile page that redirects, and the embed requests /<calLink>/embed directly - a redirect it can't follow, so nothing renders.
Should the Cal URL include the username?
No. It's the host only. Putting the username in the URL makes the embed script resolve to /<username>/embed/embed.js, which 404s.
Why doesn't changing 'NEXT_PUBLIC_CAL_URL' take effect?
NEXT_PUBLIC_* values are inlined at build time, not read at runtime. Changing the variable requires a redeploy.
Agency take
The mistake we almost made was treating the Cal embed as a UI widget bolted onto the existing hero - a popup triggered by a CTA button. It would have looked cleaner in a Figma mock. In production it threw lifecycle errors, required debugging Cal's minified internals, and didn't work reliably with App Router. Switching to an inline Contact section took less time than the afternoon we'd already spent debugging the popup.
The pattern that came out of it - #contact as a real page section, email alongside calendar, CSP derived from env vars - is what we use on client projects now. It's more robust, requires fewer moving parts, and gives users an email fallback that works without JavaScript. The only thing the popup had over the inline embed was keeping the booking UI off the main page. That's a trade-off we're happy to lose.
The embedJsUrl prop is the other thing worth locking in early. If you're deploying Cal.com's cloud version, you'll never notice it's missing. If you're self-hosting - and if you're reading a series about a self-hosted open-source Turborepo starter, you probably are - set it on day one. It takes ten seconds and saves an hour of debugging.
The starter is open source: https://github.com/DevAndDeliver/turborepo. Article 5 covers deployment: apps/web to Vercel, apps/api to Railway, and the specific env var sequencing that trip people up.
Dev and Deliver is a software agency based in Kraków, Poland. We build production web and mobile products for startups and scale-ups. devanddeliver.com
Piotr Żarów
CEO at Dev and Deliver
Share this post
Related posts
Want to light up your ideas with us?
Kickstart your new project with us in just 1 step!
Prefer to call or write a traditional e-mail?









