ποΈ
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.
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;
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}
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.
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;
ogImageAlt?: string;
favicon?: string;
keywords?: string;
heroBg?: string;
noindex?: boolean;
gaCode?: string;
}
const {
title,
description,
canonical = Astro.site?.toString() ?? '/',
themeColor = '#000000',
ogSiteName,
ogImageAlt = `${ogSiteName} app icon`,
favicon = '/favicon.png',
keywords,
heroBg,
noindex = false,
gaCode,
} = Astro.props;
const ogImage = new URL(favicon, Astro.site).toString();
const faviconType = favicon.endsWith('.svg') ? 'image/svg+xml' : 'image/png';
const ogImageType = favicon.endsWith('.svg') ? 'image/svg+xml' : '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="d0TNKYxMDAbsUS0xg22MUHw96eJ69yHbY13WT_QV1QQ" />
<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} />
<meta property="og:image:width" content="512" />
<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.
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">{ctaLabel}</a></li>}
{ctaLabel && !appHref && <li><span class="nav-cta coming-soon" title="Coming soon">{ctaLabel}</span></li>}
</ul>
</nav>
</header>
βοΈ
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.
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="/api/contact">
{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 & 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>
<p class="contact-notice">Contact is not yet configured.</p>
<button type="submit" class="submit-button" disabled>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.
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".
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>
π₯
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.
Usage
---
import DownloadSection from '@guides/ui/DownloadSection.astro';
---
<!-- Android 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 grid with optional captions. Images are lazy-loaded and have a hover lift effect. The grid is auto-fill so it adapts from 1 column on mobile to as many as fit on desktop.
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;
}
const { screenshots = [], title = 'Screenshots' } = 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>
<div class="carousel" id={uid}>
<div class="carousel-track">
{screenshots.map((s, i) => (
<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">
‹
</button>
<button class="carousel-btn next" aria-label="Next screenshot">
›
</button>
<div class="carousel-dots">
{screenshots.map((_, i) => (
<button
class={`dot ${i === 0 ? 'active' : ''}`}
aria-label={`Go to screenshot ${i + 1}`}
/>
))}
</div>
</>
)}
</div>
</div>
</section>
)}
<script 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: number) {
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;
}
.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>
π
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.
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://www.cloudflare.com/web-analytics/" target="_blank" rel="noopener noreferrer">Cloudflare Web Analytics</a>,
which is cookieless and does not track individuals. No personal data is collected and no consent banner is required.
</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);
View source
export async function handleContactForm(request: Request): 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);
}
// TODO pass in astro app name to this method so we can route to different inboxes based on the app
const appName = formData.get('appName')?.toString().trim();
const name = formData.get('name')?.toString().trim();
const email = formData.get('email')?.toString().trim();
const message = formData.get('message')?.toString().trim();
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);
}