Component Library

All components from @guides/ui. Source code is read directly from the package at build time β€” this page auto-updates whenever the components change.

πŸ—οΈ

SiteLayout@guides/ui/SiteLayout.astro

Top-level page wrapper. Composes BaseLayout, SiteNav, and Footer around your slot content. Pass a SiteConfig object for all branding β€” theme colour, emoji, nav CTA, footer links, and more.

Preview

🧭 SiteNav
← slot (your page content)

Usage

---
import SiteLayout from '@guides/ui/SiteLayout.astro';
import type { SiteConfig } from '@guides/ui/SiteLayout.astro';

const config: SiteConfig = {
  themeColor: '#cf1f2e',
  ogSiteName: 'My Site',
  emoji: '⭐',
  brandName: 'My Site',
  navCtaLabel: 'Get the app',
  navCtaHref: '/app/',
  footerTagline: 'A short description of the site.',
  footerResources: [
    { label: 'Privacy Policy', href: '/privacy' },
  ],
  copyright: 'Β© 2026 My Site.',
};
---
<SiteLayout {config} title="Page Title" description="Page description.">
  <main><!-- page content --></main>
</SiteLayout>
View source
---
import BaseLayout from './BaseLayout.astro';
import SiteNav from './SiteNav.astro';
import Footer from './Footer.astro';
import ContactPage from './ContactPage.astro';
import ThankYouPage from './ThankYouPage.astro';

export interface SiteConfig {
  themeColor: string;
  ogSiteName: string;
  ogImage?: string;
  ogImageAlt?: string;
  favicon?: string;
  emoji: string;
  brandName: string;
  navCtaLabel?: string;
  navCtaHref?: string;
  footerTagline: string;
  footerResources: Array<{ label: string; href: string }>;
  establishedYear?: number;
  copyright?: string;
  gaCode?: string;
}

interface Props {
  config: SiteConfig;
  title: string;
  description: string;
  canonical?: string;
  keywords?: string;
  heroBg?: string;
  noindex?: boolean;
  variant?: 'default' | 'contact' | 'thanks';
}

const { config, title, description, canonical, keywords, heroBg, noindex, variant = 'default' } = Astro.props;
---

<BaseLayout
  {title}
  {description}
  {canonical}
  {keywords}
  {heroBg}
  {noindex}
  themeColor={config.themeColor}
  ogSiteName={config.ogSiteName}
  ogImage={config.ogImage}
  ogImageAlt={config.ogImageAlt}
  favicon={config.favicon}
  gaCode={config.gaCode}
>
  <slot name="head" slot="head" />
  <SiteNav emoji={config.emoji} brandName={config.brandName} ctaLabel={config.navCtaLabel} appHref={config.navCtaHref} />
  {variant === 'contact' && <ContactPage webPageName={config.ogSiteName} />}
  {variant === 'thanks' && <ThankYouPage />}
  {variant === 'default' && <slot />}
  <Footer
    logoIcon={config.emoji}
    brandName={config.ogSiteName}
    tagline={config.footerTagline}
    resources={config.footerResources}
    establishedYear={config.establishedYear}
    copyright={config.copyright}
  />
</BaseLayout>
πŸ“„

BaseLayout@guides/ui/BaseLayout.astro

Raw HTML shell with full SEO metadata, Open Graph tags, favicon, and optional Google Analytics. Use this directly when you need complete control over the chrome, or let SiteLayout wrap it for you.

Preview

<head>
title meta description og:image theme-color canonical keywords Google Analytics favicon
← slot (nav + content + footer)

Usage

---
import BaseLayout from '@guides/ui/BaseLayout.astro';
---
<BaseLayout
  title="Page Title"
  description="Meta description."
  ogSiteName="My Site"
  themeColor="#cf1f2e"
  canonical="https://example.com/"
  keywords="keyword one, keyword two"
>
  <main><!-- page content --></main>
</BaseLayout>
View source
---
import '../styles/base.css';

interface Props {
  title: string;
  description: string;
  canonical?: string;
  themeColor?: string;
  ogSiteName: string;
  ogImage?: string;
  ogImageAlt?: string;
  favicon?: string;
  keywords?: string;
  heroBg?: string;
  noindex?: boolean;
  gaCode?: string;
}

const {
  title,
  description,
  canonical = Astro.site?.toString() ?? '/',
  themeColor = '#000000',
  ogSiteName,
  ogImage: ogImageProp,
  ogImageAlt = `${ogSiteName} app icon`,
  favicon = '/favicon.png',
  keywords,
  heroBg,
  noindex = false,
  gaCode,
} = Astro.props;

const ogImageSrc = ogImageProp ?? favicon;
const ogImage = new URL(ogImageSrc, Astro.site).toString();
const faviconType = favicon.endsWith('.svg') ? 'image/svg+xml' : 'image/png';
const ogImageType = ogImageSrc.endsWith('.svg') ? 'image/svg+xml' : ogImageSrc.endsWith('.webp') ? 'image/webp' : 'image/png';
const robotsContent = noindex
  ? 'noindex,nofollow'
  : 'index,follow,max-snippet:-1,max-image-preview:large,max-video-preview:-1';
---

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="google-site-verification" content="PG2CDk6pxHcmUGjSqVF_0xQScWgR7JhjcC_Tt1rjd20" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>{title}</title>
    <meta name="description" content={description} />
    {keywords && <meta name="keywords" content={keywords} />}
    <meta name="author" content="Steve Baker" />
    <meta name="robots" content={robotsContent} />
    <meta name="theme-color" content={themeColor} />

    <link rel="canonical" href={canonical} />

    <!-- Open Graph -->
    <meta property="og:type" content="website" />
    <meta property="og:url" content={canonical} />
    <meta property="og:title" content={title} />
    <meta property="og:description" content={description} />
    <meta property="og:image" content={ogImage} />
    <meta property="og:image:type" content={ogImageType} />
    {!ogImageProp && <meta property="og:image:width" content="512" />}
    {!ogImageProp && <meta property="og:image:height" content="512" />}
    <meta property="og:image:alt" content={ogImageAlt} />
    <meta property="og:site_name" content={ogSiteName} />
    <meta property="og:locale" content="en_US" />

    <!-- Twitter Card -->
    <meta name="twitter:card" content="summary_large_image" />
    <meta name="twitter:url" content={canonical} />
    <meta name="twitter:title" content={title} />
    <meta name="twitter:description" content={description} />
    <meta name="twitter:image" content={ogImage} />

    <!-- App metadata -->
    <meta name="application-name" content={ogSiteName} />
    <meta name="apple-mobile-web-app-title" content={ogSiteName} />
    <meta name="apple-mobile-web-app-capable" content="yes" />
    <meta name="format-detection" content="telephone=no" />

    <link rel="icon" type={faviconType} href={favicon} />
    <link rel="apple-touch-icon" sizes="180x180" href={favicon} />

    <slot name="head" />
    {heroBg && <style set:html={`:root { --hero-bg-image: url('${heroBg}'); }`} />}

    {gaCode && (
      <>
        <script async src={`https://www.googletagmanager.com/gtag/js?id=${gaCode}`}></script>
        <script set:html={`window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','${gaCode}');`}></script>
      </>
    )}

    <link rel="stylesheet" href="/styles/global.css" />
  </head>
  <body>
    <slot />
  </body>
</html>
🧭

SiteNav@guides/ui/SiteNav.astro

Sticky top navigation bar. Shows a brand logo (emoji + name) linking to /, a row of nav links (About, FAQ, Contact), and an optional CTA button. Collapses the nav links on narrow screens.

Preview

Usage

---
import SiteNav from '@guides/ui/SiteNav.astro';
---
<SiteNav
  emoji="⭐"
  brandName="My Site"
  ctaLabel="Get the app"
  appHref="/app/"
/>
View source
---
interface Props {
  emoji: string;
  brandName: string;
  appHref?: string;
  ctaLabel?: string;
}

const { emoji, brandName, appHref = '/app/', ctaLabel = 'Get the app' } = Astro.props;
---

<header class="site-header">
  <nav class="site-nav" aria-label="Primary">
    <a class="nav-brand" href="/">{emoji} {brandName}</a>
    <ul>
      <li><a href="/#about">About</a></li>
      <li><a href="/#faq">FAQ</a></li>
      <li><a href="/contact">Contact</a></li>
      {ctaLabel && appHref && <li><a href={appHref} class="nav-cta" set:html={ctaLabel}></a></li>}
      {ctaLabel && !appHref && <li><span class="nav-cta coming-soon" title="Coming soon" set:html={ctaLabel}></span></li>}
    </ul>
  </nav>
</header>
🦢

Footer@guides/ui/Footer.astro

Site footer with logo, tagline, resource links, and auto-generated copyright. External resource links open in a new tab. Renders a gradient top border using --primary and --accent CSS variables.

Preview

Usage

---
import Footer from '@guides/ui/Footer.astro';
---
<Footer
  logoIcon="⭐"
  brandName="My Site"
  tagline="A short site description."
  resources={[
    { label: 'Game Wiki', href: 'https://wiki.example.com' },
    { label: 'Privacy Policy', href: '/privacy' },
  ]}
  copyright="Β© 2026 My Site."
/>
View source
---
interface Props {
  logoIcon: string;
  brandName: string;
  tagline: string;
  resources: Array<{ label: string; href: string }>;
  copyright?: string;
  establishedYear?: number;
}

const { logoIcon, brandName, tagline, resources, copyright, establishedYear } = Astro.props;

const currentYear = new Date().getFullYear();
const copyrightText = establishedYear
  ? `Β© ${establishedYear < currentYear ? `${establishedYear} – ${currentYear}` : currentYear} ${brandName}.`
  : (copyright ?? `Β© ${currentYear} ${brandName}.`);
---

<footer class="footer">
  <div class="footer-container">
    <div class="footer-content">
      <div class="footer-section">
        <div class="footer-logo">
          <span class="logo-icon" aria-hidden="true">{logoIcon}</span>
          {brandName}
        </div>
        <p>{tagline}</p>
      </div>
      <div class="footer-section">
        <h4>Resources</h4>
        <ul>
          {resources.map((r) => (
            <li>
              {r.href.startsWith('http')
                ? <a href={r.href} target="_blank" rel="noopener noreferrer">{r.label}</a>
                : <a href={r.href}>{r.label}</a>
              }
            </li>
          ))}
        </ul>
      </div>
    </div>
    <div class="footer-bottom">
      <p>{copyrightText}</p>
    </div>
  </div>
</footer>
🌟

HeroSection@guides/ui/HeroSection.astro

Full-width hero banner with a gradient background driven by --primary and its variants. Accepts a title, subtitle, description, and an optional slot for CTA buttons or badges.

Preview

Dragon Quest Builders 2

The Complete Building Guide

Everything you need to master crafting, building, and exploring.

Usage

---
import HeroSection from '@guides/ui/HeroSection.astro';
---
<HeroSection
  title="Dragon Quest Builders 2"
  subtitle="The Complete Building Guide"
  description="Everything you need to master crafting, building, and exploring."
>
  <!-- Optional slot: CTA buttons, badges, etc. -->
  <a href="/download" class="cta-button">Download App</a>
</HeroSection>
View source
---
interface Props {
  title: string;
  subtitle: string;
  description: string;
}

const { title, subtitle, description } = Astro.props;
---

<section id="home" class="hero">
  <div class="hero-content">
    <h1>{title}</h1>
    <h2>{subtitle}</h2>
    <p>{description}</p>
    <slot />
  </div>
</section>
πŸ“₯

DownloadSection@guides/ui/DownloadSection.astro

App download CTA section. Renders a Google Play badge if playStoreUrl is provided, a plain download button if downloadUrl is given, and/or a "use in browser" web app link.

Preview

APK download variant

Get the Android App

Install the APK directly from GitHub Releases.

Download APK

Google Play + web fallback variant

Download Now

Available on Android and in your browser.

Download on Google Play

Usage

---
import DownloadSection from '@guides/ui/DownloadSection.astro';
---
<!-- APK from GitHub Releases -->
<DownloadSection
  heading="Get the Android App"
  description="Install the APK from GitHub Releases."
  downloadUrl="https://github.com/you/your-app/releases"
  downloadAriaLabel="Download My App APK"
/>

<!-- Google Play + web fallback -->
<DownloadSection
  heading="Download Now"
  playStoreUrl="https://play.google.com/store/apps/details?id=com.example"
  webAppUrl="https://app.example.com"
  webAppLabel="web app"
/>
View source
---
interface Props {
  heading: string;
  description?: string;
  playStoreUrl?: string;
  downloadUrl?: string;
  downloadAriaLabel?: string;
  downloadAlt?: string;
  webAppUrl?: string;
  webAppLabel?: string;
}

const {
  heading,
  description,
  playStoreUrl,
  downloadUrl,
  downloadAriaLabel,
  downloadAlt = 'Download on Google Play',
  webAppUrl,
  webAppLabel = 'web app',
} = Astro.props;

const badgeHref = playStoreUrl ?? downloadUrl;
const isExternal = (url: string) => url.startsWith('http');
---

<section class="download-section">
  <div class="container">
    <h2>{heading}</h2>
    {description && <p>{description}</p>}
    {playStoreUrl && (
      <a
        href={playStoreUrl}
        target="_blank"
        rel="noopener noreferrer"
        class="store-badge"
        aria-label={downloadAriaLabel}
      >
        <img
          src="https://upload.wikimedia.org/wikipedia/commons/7/78/Google_Play_Store_badge_EN.svg"
          alt={downloadAlt}
          width="201"
          height="60"
        />
      </a>
    )}
    {!playStoreUrl && downloadUrl && (
      <a
        href={downloadUrl}
        target="_blank"
        rel="noopener noreferrer"
        class="cta-button"
        aria-label={downloadAriaLabel}
      >
        {downloadAlt}
      </a>
    )}
    {!badgeHref && webAppUrl && (
      <a
        href={webAppUrl}
        class="cta-button"
        {...(isExternal(webAppUrl) ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
      >
        {webAppLabel}
      </a>
    )}
    {!badgeHref && !webAppUrl && (
      <span class="cta-button coming-soon" title="Coming soon">Coming Soon</span>
    )}
    {badgeHref && webAppUrl && (
      <p class="web-app-link">
        Or use the{' '}
        <a
          href={webAppUrl}
          {...(isExternal(webAppUrl) ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
        >
          {webAppLabel}
        </a>{' '}
        in your browser.
      </p>
    )}
  </div>
</section>

<style is:global>
  .download-section {
    background: var(--download-section-bg, var(--surface));
    text-align: center;
    padding: 72px 24px;
  }

  .download-section h2 { margin-bottom: 0.75rem; }
  .download-section p  { margin-bottom: 1.75rem; }
  .store-badge img     { display: inline-block; }

  .web-app-link {
    margin-top: 1.25rem;
    font-size: 0.95rem;
  }

  .web-app-link a {
    color: var(--brand-light, var(--green, #4caf50));
    font-weight: 600;
    text-decoration: none;
  }

  .web-app-link a:hover { text-decoration: underline; }
</style>
πŸ–ΌοΈ

Screenshots@guides/ui/Screenshots.astro

Responsive screenshot carousel with optional captions. Images are lazy-loaded. Shows one slide at a time with prev/next buttons (visible on hover) and dot indicators.

Preview

App Screenshots

Usage

---
import Screenshots from '@guides/ui/Screenshots.astro';
---
<Screenshots
  title="Screenshots"
  screenshots={[
    { src: '/images/screen-1.png', alt: 'Main screen', caption: 'Track your progress' },
    { src: '/images/screen-2.png', alt: 'Detail view' },
  ]}
/>
View source
---
interface Screenshot {
  src: string;
  alt: string;
  caption?: string;
}

interface Props {
  screenshots?: Screenshot[];
  title?: string;
  layout?: 'carousel' | 'gallery';
}

const { screenshots = [], title = 'Screenshots', layout = 'carousel' } = Astro.props;
const uid = `ss-${Math.random().toString(36).slice(2, 8)}`;
---

{screenshots.length > 0 && (
  <section class="screenshots-section">
    <div class="container">
      <h2>{title}</h2>

      {layout === 'gallery' ? (
        <div class={`gallery ${screenshots.length === 1 ? 'gallery--single' : 'gallery--multi'}`}>
          {screenshots.map((s) => (
            <figure class="gallery-figure">
              <img src={s.src} alt={s.alt} loading="lazy" />
              {s.caption && <figcaption>{s.caption}</figcaption>}
            </figure>
          ))}
        </div>
      ) : (
        <div class="carousel" id={uid}>
          <div class="carousel-track">
            {screenshots.map((s: Screenshot, i: number) => (
              <figure class={`carousel-slide ${i === 0 ? 'active' : ''}`}>
                <img src={s.src} alt={s.alt} loading="lazy" />
                {s.caption && <figcaption>{s.caption}</figcaption>}
              </figure>
            ))}
          </div>
          {screenshots.length > 1 && (
            <>
              <button class="carousel-btn prev" aria-label="Previous screenshot">
                &#8249;
              </button>
              <button class="carousel-btn next" aria-label="Next screenshot">
                &#8250;
              </button>
              <div class="carousel-dots">
                {screenshots.map((_: Screenshot, i: number) => (
                  <button
                    class={`dot ${i === 0 ? 'active' : ''}`}
                    aria-label={`Go to screenshot ${i + 1}`}
                  />
                ))}
              </div>
            </>
          )}
        </div>
      )}
    </div>
  </section>
)}

{layout === 'carousel' && (
  <script is:inline define:vars={{ uid }}>
    const root = document.getElementById(uid);
    if (!root) return;

    const slides = [...root.querySelectorAll('.carousel-slide')];
    const dots = [...root.querySelectorAll('.dot')];
    const prevBtn = root.querySelector('.prev');
    const nextBtn = root.querySelector('.next');
    let current = 0;

    function goTo(index) {
      slides[current].classList.remove('active');
      dots[current]?.classList.remove('active');
      current = (index + slides.length) % slides.length;
      slides[current].classList.add('active');
      dots[current]?.classList.add('active');
    }

    prevBtn?.addEventListener('click', () => goTo(current - 1));
    nextBtn?.addEventListener('click', () => goTo(current + 1));
    dots.forEach((dot, i) => dot.addEventListener('click', () => goTo(i)));
  </script>
)}

<style is:global>
  .screenshots-section {
    background: var(--white);
    padding: 72px 0;
  }

  /* Gallery layout */
  .gallery {
    display: flex;
    justify-content: center;
    gap: 2rem;
    flex-wrap: wrap;
  }

  .gallery-figure {
    margin: 0;
    border-radius: 14px;
    overflow: hidden;
    box-shadow: 0 8px 32px rgba(0, 0, 0, 0.14);
    border: 1.5px solid var(--border);
    background: var(--surface);
    flex: 1 1 300px;
    max-width: 520px;
  }

  .gallery--single .gallery-figure {
    flex: none;
    width: 100%;
    max-width: 640px;
  }

  .gallery-figure img {
    width: 100%;
    height: auto;
    display: block;
  }

  .gallery-figure figcaption {
    padding: 0.75rem 1rem;
    font-size: 0.9rem;
    color: var(--text-muted);
    text-align: center;
  }

  /* Carousel layout */
  .carousel {
    position: relative;
    max-width: 420px;
    margin: 0 auto;
    overflow: hidden;
    border-radius: 12px;
    border: 1.5px solid var(--border);
    background: var(--surface);
  }

  .carousel-track {
    display: flex;
    transition: transform 0.3s ease;
  }

  .carousel-slide {
    min-width: 100%;
    margin: 0;
    opacity: 0;
    position: absolute;
    inset: 0;
    transition: opacity 0.3s ease;
    pointer-events: none;
  }

  .carousel-slide.active {
    opacity: 1;
    position: relative;
    pointer-events: auto;
  }

  .carousel-slide img {
    width: 100%;
    max-height: 360px;
    object-fit: contain;
    display: block;
    background: var(--surface);
  }

  .carousel-slide figcaption {
    padding: 0.75rem 1rem;
    font-size: 0.9rem;
    color: var(--text-muted);
    text-align: center;
  }

  .carousel-btn {
    position: absolute;
    top: 40%;
    transform: translateY(-50%);
    width: 36px;
    height: 36px;
    border-radius: 50%;
    border: none;
    background: rgba(0, 0, 0, 0.5);
    color: #fff;
    font-size: 1.5rem;
    line-height: 1;
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
    opacity: 0;
    transition: opacity 0.2s;
    z-index: 2;
  }

  .carousel:hover .carousel-btn {
    opacity: 1;
  }

  .carousel-btn:hover {
    background: rgba(0, 0, 0, 0.7);
  }

  .carousel-btn.prev { left: 8px; }
  .carousel-btn.next { right: 8px; }

  .carousel-dots {
    display: flex;
    justify-content: center;
    gap: 8px;
    padding: 12px 0;
  }

  .dot {
    width: 10px;
    height: 10px;
    border-radius: 50%;
    border: none;
    background: var(--border);
    cursor: pointer;
    padding: 0;
    transition: background 0.2s, transform 0.2s;
  }

  .dot.active {
    background: var(--primary);
    transform: scale(1.2);
  }

  .dot:hover:not(.active) {
    background: var(--text-muted);
  }
</style>
βœ‰οΈ

ContactForm@guides/ui/ContactForm.astro

Honeypot-protected contact form with name, email, subject dropdown, and message fields. Posts to /api/contact β€” wire up the Cloudflare Pages function using @guides/workers to handle it.

Preview

Send us a message

Usage

---
import ContactForm from '@guides/ui/ContactForm.astro';
---
<ContactForm />
View source
---
interface Props {
  webPageName?: string;
}

const { webPageName } = Astro.props;
---

<section id="contact" class="contact-content">
  <div class="container">
    <div class="contact-single">
      <div class="contact-form-section">
        <h1>{webPageName ? `Contact ${webPageName}` : 'Send us a message'}</h1>
        <form class="contact-form" method="post" action="https://formspree.io/f/xbdbakov">
          {webPageName && <input type="hidden" name="site" value={webPageName} />}
          <div class="form-group" style="display:none">
            <label>Don't fill this out if you're human: <input name="bot-field" /></label>
          </div>
          <div class="form-group">
            <label for="name">Name</label>
            <input type="text" id="name" name="name" required />
          </div>
          <div class="form-group">
            <label for="email">Email</label>
            <input type="email" id="email" name="email" required />
          </div>
          <div class="form-group">
            <label for="subject">Subject</label>
            <select id="subject" name="subject" required>
              <option value="">Select a subject</option>
              <option value="bug">Bug Report</option>
              <option value="feature">Feature Request</option>
              <option value="help">Help &amp; Support</option>
              <option value="feedback">General Feedback</option>
              <option value="other">Other</option>
            </select>
          </div>
          <div class="form-group">
            <label for="message">Message</label>
            <textarea id="message" name="message" rows="6" required></textarea>
          </div>
          
          <input type="hidden" name="_subject" value="New contact form submission" />
          <input type="hidden" name="_next" value="/thank-you" />

          <button type="submit" class="submit-button">Send Message</button>
        </form>
      </div>
    </div>
  </div>
</section>
πŸ“¬

ContactPage@guides/ui/ContactPage.astro

ContactForm wrapped in a <main> element. Used automatically by SiteLayout when you pass variant="contact" β€” no need to import it directly in most cases.

Preview

Contact My Site

Usage

---
import Layout from '../layouts/Layout.astro';
---
<!-- Simplest approach β€” use the variant prop: -->
<Layout
  title="Contact – My Site"
  description="Get in touch."
  variant="contact"
/>
View source
---
import ContactForm from './ContactForm.astro';

interface Props {
  webPageName?: string;
}

const { webPageName } = Astro.props;
---

<main>
  <ContactForm {webPageName} />
</main>
πŸ™

ThankYouPage@guides/ui/ThankYouPage.astro

Confirmation page rendered after a successful contact form submission. Used automatically by SiteLayout when you pass variant="thanks".

Preview

Message sent!

Thanks for reaching out

We've received your message and will get back to you soon.

Back to Home

Usage

---
import Layout from '../layouts/Layout.astro';
---
<Layout
  title="Message Sent – My Site"
  description="Your message has been received."
  variant="thanks"
/>
View source
---
---

<main>
  <section class="hero" style="min-height: 60vh; display: flex; align-items: center;">
    <div class="hero-content">
      <h1>Message sent!</h1>
      <h2>Thanks for reaching out</h2>
      <p>We've received your message and will get back to you soon.</p>
      <a href="/" class="cta-button" style="margin-top: 1rem;">Back to Home</a>
    </div>
  </section>
</main>
πŸ”’

PrivacyPage@guides/ui/PrivacyPage.astro

Pre-written privacy policy covering data collection, local storage, contact form, and Cloudflare Web Analytics. Customise the site name, last updated date, and storage description. Add extra sections via the default slot.

Preview

Privacy Policy

Last updated: May 2026

What we collect

My Site does not collect, store, or share any personal data. There are no accounts, no sign-ups, and no ads.

Local storage

The app saves your progress locally on your device. This data never leaves your device.

Contact form

If you use the contact form, your name, email address, and message are submitted for the sole purpose of replying to your enquiry. This information is not shared with third parties.

Analytics

This site uses Google Analytics 4 and Cloudflare Web Analytics to understand how visitors use the site. Cloudflare Web Analytics is cookieless and collects no personal data. Google Analytics may set cookies to collect anonymous usage data such as pages visited and session duration; no personally identifiable information is collected. See Google's Privacy Policy for details.

Contact

Questions? Get in touch.

Usage

---
import Layout from '../layouts/Layout.astro';
import PrivacyPage from '@guides/ui/PrivacyPage.astro';
---
<Layout
  title="Privacy Policy – My Site"
  description="Privacy policy."
  noindex={true}
>
  <PrivacyPage
    siteName="My Site"
    lastUpdated="May 2026"
    localStorageDescription="The app saves your progress locally. This data never leaves your device."
    thirdPartyLinks={[]}
  />
</Layout>
View source
---
interface Props {
  siteName: string;
  lastUpdated?: string;
  localStorageDescription?: string;
  thirdPartyLinks?: string[];
  contactHref?: string;
}

const {
  siteName,
  lastUpdated,
  localStorageDescription = 'The app saves your progress locally on your device using your browser\'s local storage. This data never leaves your device.',
  thirdPartyLinks = ['Google Play'],
  contactHref = '/contact',
} = Astro.props;

const thirdPartyText = thirdPartyLinks.length === 1
  ? thirdPartyLinks[0]
  : thirdPartyLinks.slice(0, -1).join(', ') + ' and ' + thirdPartyLinks.at(-1);
---

<main>
  <section class="privacy-section">
    <div class="container">
      <h1>Privacy Policy</h1>
      {lastUpdated && <p class="updated">Last updated: {lastUpdated}</p>}

      <h2>What we collect</h2>
      <p>{siteName} does not collect, store, or share any personal data. There are no accounts, no sign-ups, and no ads.</p>

      <h2>Local storage</h2>
      <p>{localStorageDescription}</p>

      <h2>Contact form</h2>
      <p>If you use the contact form, your name, email address, and message are submitted for the sole purpose of replying to your enquiry. This information is not shared with third parties.</p>

      <h2>Analytics</h2>
      <p>
        This site uses <a href="https://marketingplatform.google.com/about/analytics/" target="_blank" rel="noopener noreferrer">Google Analytics 4</a>
        and <a href="https://www.cloudflare.com/web-analytics/" target="_blank" rel="noopener noreferrer">Cloudflare Web Analytics</a>
        to understand how visitors use the site. Cloudflare Web Analytics is cookieless and collects no personal data.
        Google Analytics may set cookies to collect anonymous usage data such as pages visited and session duration;
        no personally identifiable information is collected.
        See <a href="https://policies.google.com/privacy" target="_blank" rel="noopener noreferrer">Google's Privacy Policy</a> for details.
      </p>

      {thirdPartyLinks.length > 0 && (
        <>
          <h2>Third-party links</h2>
          <p>This site links to {thirdPartyText}. Their own privacy policies apply when you visit those services.</p>
        </>
      )}

      <slot />

      <h2>Contact</h2>
      <p>Questions? <a href={contactHref}>Get in touch.</a></p>
    </div>
  </section>
</main>

<style>
  .privacy-section {
    background: var(--surface);
    padding: 72px 0;
  }

  .privacy-section .container {
    max-width: 720px;
  }

  h1 {
    font-size: clamp(1.8rem, 4vw, 2.4rem);
    font-weight: 800;
    color: var(--text);
    margin-bottom: 0.5rem;
    letter-spacing: -0.02em;
    text-align: left;
  }

  .updated {
    font-size: 0.9rem;
    color: var(--text-muted);
    margin-bottom: 2rem;
  }

  h2 {
    text-align: left;
    font-size: 1.2rem;
    margin-top: 2rem;
    margin-bottom: 0.6rem;
  }

  a {
    color: var(--primary);
    text-decoration: none;
  }

  a:hover {
    text-decoration: underline;
  }
</style>
πŸ—ΊοΈ

createSitemap()@guides/ui/sitemap

Wraps @astrojs/sitemap with sensible defaults: excludes /contact, /thank-you, and /privacy from the generated sitemap. Pass extra paths to exclude if needed.

Usage

// astro.config.mjs
import { defineConfig } from 'astro/config';
import { createSitemap } from '@guides/ui/sitemap';

export default defineConfig({
  site: 'https://example.pages.dev',
  integrations: [createSitemap()],
  // Pass extra paths to exclude:
  // integrations: [createSitemap(['/secret-page'])],
});
View source
import sitemap from '@astrojs/sitemap';

const DEFAULT_EXCLUDE = ['/contact', '/thank-you', '/privacy'];

export function createSitemap(extraExclude: string[] = []) {
  const excluded = [...DEFAULT_EXCLUDE, ...extraExclude];
  return sitemap({
    filter: (page: string) => !excluded.some((path) => page.includes(path)),
  });
}
⚑

handleContactForm()@guides/workers/contact

Drop-in Cloudflare Pages Function handler for the contact form. Validates fields, checks the honeypot, and redirects to /thank-you on success. Returns 400 if required fields are missing.

Usage

// functions/api/contact.ts
import type { EventContext } from '@cloudflare/workers-types';
import { handleContactForm } from '@guides/workers/contact';

export const onRequestPost = (
  context: EventContext<Record<string, unknown>, string, Record<string, unknown>>
) => handleContactForm(context.request, 'my-new-site');
View source
export async function handleContactForm(request: Request, appName: string): Promise<Response> {
  const formData = await request.formData();

  const botField = formData.get('bot-field');
  if (botField) {
    return Response.redirect(new URL('/thank-you', request.url).toString(), 303);
  }

  const app = appName.trim();
  const name = formData.get('name')?.toString().trim();
  const email = formData.get('email')?.toString().trim();
  const message = formData.get('message')?.toString().trim();

  if (!app) {
    return new Response('Missing app name', { status: 500 });
  }

  if (!name || !email || !message) {
    return new Response('Missing required fields', { status: 400 });
  }

  // TODO: wire up an email provider (e.g. Resend, SendGrid, Cloudflare Email Workers)

  return Response.redirect(new URL('/thank-you', request.url).toString(), 303);
}