Integrations
Every Facade template ships with small server modules that make it work the moment you deploy: lead forms send email, the booking picker books, and reviews and opening hours come from Google. There are no extra npm packages, and each integration runs in demo mode until you add its key, so a fresh deploy never breaks.
1. Copy the files
The integrations live in templates/_shared/integrations and three ready made route handlers in templates/_shared/api. Copy them into your project:
cp -r templates/_shared/integrations lib/integrations
mkdir -p app/api/lead app/api/booking app/api/busy
cp templates/_shared/api/lead/route.ts app/api/lead/route.ts
cp templates/_shared/api/booking/route.ts app/api/booking/route.ts
cp templates/_shared/api/busy/route.ts app/api/busy/route.tsThen point the import at the top of each route to the new folder, for example @/lib/integrations/lead.
2. Add your keys
Put the variables you need in .env.local and in your host settings. Leave one out and that integration stays in demo mode. None of them are public, so keys never reach the browser.
# Leads and bookings (Resend)
RESEND_API_KEY=re_xxxxxxxx
LEAD_TO_EMAIL=owner@yourbusiness.com
LEAD_FROM_EMAIL="Your Business <hello@yourbusiness.com>"
LEAD_WEBHOOK_URL= # optional: Zapier, Make, n8n
BUSINESS_NAME="Your Business"
# Booking
CALCOM_LINK= # e.g. yourname/30min, switches to Cal.com
BOOKING_TIME_ZONE=America/New_York
BOOKING_BUSY_ICS_URL= # optional: Google Calendar secret iCal address
# Google reviews and opening hours
GOOGLE_PLACES_API_KEY=
GOOGLE_PLACE_ID=Lead forms
contact-section, multi-step-lead-form and valuation-funnel post to /api/lead. The route validates the fields, checks a honeypot, rate limits by IP, emails every field to LEAD_TO_EMAIL through Resend and, if set, forwards the lead to LEAD_WEBHOOK_URL for Zapier, Make or n8n. Reply to the email and you answer the lead directly.
"use client";
import { ContactSection } from "@/components/facade/contact-section";
import { submitLead } from "@/lib/integrations/client";
export function Contact() {
return <ContactSection onSubmit={(values) => submitLead({ ...values, form: "contact" })} />;
}submitLead throws when the server rejects a lead, and the Facade forms catch that and stay on the form. On the server you can call sendLead yourself. It never throws:
import { sendLead } from "@/lib/integrations/lead";
const result = await sendLead({ name, email, phone, message }, { ip, form: "contact" });
// { ok: true, mode: "sent" | "demo" | "ignored" } or { ok: false, error, status, fieldErrors }Add the hidden honeypot field to any custom form. Bots fill it, people never see it:
<input type="text" name="hp_website" tabIndex={-1} autoComplete="off" aria-hidden="true" className="hidden" />- Rate limit: 5 leads per IP per 10 minutes, kept in memory per server instance. Use your host firewall for a hard global limit.
- Keys: create an account at resend.com, verify your domain and create an API key. The free plan covers 3,000 emails a month.
Booking
Option A: Cal.com
Set CALCOM_LINK to your event link and render the button from a server component. It opens Cal.com in a new tab or as an inline iframe, without the Cal SDK. Cal.com handles availability, reminders and rescheduling, and its free plan has unlimited bookings for individuals.
import { getCalUrl, getCalEmbedUrl } from "@/lib/integrations/booking";
import { CalEmbedButton } from "@/lib/integrations/cal-embed";
// New tab
<CalEmbedButton href={getCalUrl()} label="Book an estimate" />
// Inline iframe that opens on click
<CalEmbedButton href={getCalEmbedUrl({ theme: "light" })} mode="inline" />Option B: Native booking
Keep the booking-slots picker. /api/booking emails the business and sends the customer a confirmation with an .ics file that adds the appointment to any calendar. Set BOOKING_BUSY_ICS_URL to the secret iCal address of a Google Calendar and /api/busy disables slots that are already taken. The feed is cached for 5 minutes.
"use client";
import * as React from "react";
import { BookingSlots } from "@/components/facade/booking-slots";
import { fetchBusySlots, submitBooking, toDateKey } from "@/lib/integrations/client";
export function Booking({ name, email, phone }: { name: string; email: string; phone: string }) {
const [busy, setBusy] = React.useState<Record<string, string[]>>({});
React.useEffect(() => {
const today = new Date();
for (let i = 0; i < 14; i++) {
const key = toDateKey(new Date(today.getFullYear(), today.getMonth(), today.getDate() + i));
fetchBusySlots(key).then((slots) => setBusy((b) => ({ ...b, [key]: slots })));
}
}, []);
return (
<BookingSlots
serviceName="Cleaning and exam"
isSlotDisabled={(date, time) => busy[toDateKey(date)]?.includes(time) ?? false}
onConfirm={({ date, time }) =>
submitBooking({ slotISO: `${date}T${time}`, service: "Cleaning and exam", name, email, phone })
}
/>
);
}Native booking does not keep its own database. Add each booking to the calendar behind the busy feed (one click on the attached file) so the slot shows as taken. For strict double booking protection, use Cal.com.
Google reviews
getGoogleReviews reads rating, review count and up to 5 reviews from the Places API (New), cached for 6 hours, and maps them for both review-marquee and testimonials-wall. Without a key or on any error it returns the static reviews you pass in.
import { getGoogleReviews } from "@/lib/integrations/google-reviews";
import { ReviewMarquee } from "@/components/facade/review-marquee";
import { TestimonialsWall } from "@/components/facade/testimonials-wall";
import { reviews as fallbackReviews } from "./content";
export default async function Reviews() {
const g = await getGoogleReviews(process.env.GOOGLE_PLACE_ID, fallbackReviews);
return (
<>
<ReviewMarquee reviews={g.reviews} />
<TestimonialsWall rating={g.rating} reviewCount={g.reviewCount} testimonials={g.testimonials} />
{g.attribution.show && (
<a href={g.attribution.url} className="text-sm text-muted-foreground">
{g.attribution.label}
</a>
)}
</>
);
}Google requires attribution. When attribution.required is true, show "Reviews from Google" near the reviews, keep the author name and relative time as returned and do not edit the text.
Opening hours
getOpeningHours reads regularOpeningHours and the time zone from the same API and maps them to opening-hours, open-now-badge and contact-section. Update the hours in Google Business Profile and the site follows within 6 hours.
import { getOpeningHours } from "@/lib/integrations/opening-hours";
import { OpeningHours } from "@/components/facade/opening-hours";
import { OpenNowBadge } from "@/components/facade/open-now-badge";
const h = await getOpeningHours(process.env.GOOGLE_PLACE_ID, fallbackHours, "America/Chicago");
<OpenNowBadge hours={h.hours} timeZone={h.timeZone} />
<OpeningHours hours={h.hours} timeZone={h.timeZone} />
<ContactSection hours={h.contactHours} timeZone={h.timeZone} />Keys: enable Places API (New) in Google Cloud Console, create an API key restricted to it and look up your place id with Google's Place ID Finder. Google bills per request after a free monthly allowance. With the 6 hour cache a typical site stays inside it, but set a budget alert.
Demo mode
Without keys, leads return { ok: true, mode: "demo" }, bookings return the booking and its .ics without sending email, busy slots are empty, and reviews and hours use your static content. The full setup guide with every variable is in templates/_shared/integrations/README.md.