Skip to content

Building From Scratch

This guide covers building a new website directly on Cloudflare Pages — no WordPress migration involved. Whether you need a simple landing page or a full practice website with blog, forms, and content management, this document walks through every step.

Two approaches covered:

  1. Plain HTML/CSS/JS - Simplest path. Best for 1-5 page sites and landing pages.
  2. Astro (Recommended) - Cloudflare’s first-party web framework. Best for 5+ page sites, blogs, and content-driven sites.

What this means in practice:

  • Astro has first-class Cloudflare Pages integration out of the box
  • Cloudflare invests directly in Astro’s development and optimization
  • Astro is the default recommendation for new projects in Cloudflare documentation
  • Tighter integration with Cloudflare Workers, KV, D1, and other platform features
  • Long-term stability — Cloudflare is committed to Astro as their framework layer

For medical practice, med spa, and plastic surgery websites — which are content-driven marketing sites — Astro is the ideal fit. It was built specifically for this category of website.


flowchart TD
    A[How many pages will the site have?] -->|1-5 pages, no blog, minimal updates| B[Plain HTML/CSS/JS]
    A -->|5+ pages, or blog, or regular content updates| C[Astro - Recommended]
    A -->|Complex interactivity, React/Vue/Svelte app| D[Other frameworks]
    B --> B1[Fastest to build, zero tooling overhead]
    B1 --> B2[Deploy public/ folder directly]
    C --> C1[Component-based, Content Collections]
    C1 --> C2[Built-in image optimization, CMS support]
    D --> D1[Next.js, Nuxt, SvelteKit]
    D1 --> D2[All work on Cloudflare Pages]
CriteriaPlain HTMLAstroNext.js / Nuxt / SvelteKit
Best forLanding pages, 1-5 page sitesMarketing sites, blogs, 5-50+ pagesWeb applications, heavy interactivity
Build stepNoneYes (npm run build)Yes
Learning curveMinimalLow-mediumMedium-high
Component reuseManual copy/pasteBuilt-in components, layoutsBuilt-in
Blog supportManual HTML per postContent Collections (Markdown/MDX)Varies
Image optimizationManual (external tools)Built-in (astro:assets)Framework-dependent
CMS integrationManualFirst-class (Decap, Tina, etc.)Varies
Cloudflare integrationBasic (static files)First-party (acquired)Adapter required
AI editabilityDirect HTML editingComponent + Markdown editingVaries
Setup time5 minutes10 minutes15-30 minutes
Typical build time0 seconds2-10 seconds10-60 seconds

Plain HTML

Use when:

  • You need a single landing page for a PPC campaign
  • The site has 1-5 pages and will rarely change
  • You want zero build tooling
  • The team only knows HTML/CSS

Astro (Recommended)

Use when:

  • The site has 5+ pages
  • You want reusable components (header, footer, service cards)
  • You need a blog or news section
  • Non-technical staff need to edit content (CMS integration)
  • You want built-in image optimization
  • You are building a template for multiple practice sites

Other Frameworks

Use when:

  • The site is really a web application (dashboards, patient portals)
  • You need heavy client-side interactivity
  • Your team already has deep expertise in Next.js/Nuxt/SvelteKit

my-practice-site/
├── public/
│ ├── index.html # Homepage
│ ├── about.html # About page
│ ├── services.html # Services page
│ ├── contact.html # Contact page
│ ├── 404.html # Custom 404 page
│ ├── _headers # Security + caching headers
│ ├── _redirects # URL redirects (optional)
│ ├── css/
│ │ └── styles.css # Site styles
│ ├── js/
│ │ ├── main.js # Site scripts
│ │ └── form-handler.js # Form submission logic
│ ├── images/
│ │ ├── logo.svg
│ │ ├── hero.webp
│ │ └── ...
│ └── fonts/
│ └── ...
├── functions/
│ └── api/
│ └── submit-form.js # Cloudflare Function (form handler)
├── wrangler.toml # Cloudflare config
├── package.json
├── .gitignore
└── .env # API keys (not committed)
  1. Create the project

    Terminal window
    mkdir my-practice-site
    cd my-practice-site
    npm init -y
    npm install wrangler --save-dev
    mkdir -p public/css public/js public/images public/fonts
    mkdir -p functions/api
  2. Create wrangler.toml

    name = "my-practice-site"
    compatibility_date = "2024-01-01"
    pages_build_output_dir = "public"
  3. Create public/index.html

    <!DOCTYPE html>
    <html lang="en">
    <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Bay Area Dermatology | Expert Skin Care</title>
    <meta name="description" content="Board-certified dermatologists in San Jose, CA. Specializing in medical and cosmetic dermatology. Call (408) 555-0100.">
    <!-- Open Graph -->
    <meta property="og:title" content="Bay Area Dermatology | Expert Skin Care">
    <meta property="og:description" content="Board-certified dermatologists in San Jose, CA.">
    <meta property="og:type" content="website">
    <meta property="og:url" content="https://www.bayareadermatology.com/">
    <!-- Styles -->
    <link rel="stylesheet" href="/css/styles.css">
    <!-- Favicon -->
    <link rel="icon" href="/images/favicon.ico">
    <!-- Preload critical assets -->
    <link rel="preload" href="/images/hero.webp" as="image">
    </head>
    <body>
    <header class="site-header">
    <nav class="nav-container">
    <a href="/" class="logo">
    <img src="/images/logo.svg" alt="Bay Area Dermatology" width="200" height="50">
    </a>
    <ul class="nav-links">
    <li><a href="/">Home</a></li>
    <li><a href="/services.html">Services</a></li>
    <li><a href="/about.html">About</a></li>
    <li><a href="/contact.html">Contact</a></li>
    </ul>
    <a href="tel:+14085550100" class="nav-cta">Call (408) 555-0100</a>
    </nav>
    </header>
    <section class="hero">
    <div class="hero-content">
    <h1>Expert Dermatology Care in the Bay Area</h1>
    <p>Board-certified dermatologists providing medical, surgical, and cosmetic skin care.</p>
    <a href="/contact.html" class="btn btn-primary">Book a Consultation</a>
    </div>
    <img src="/images/hero.webp" alt="Modern dermatology office" class="hero-image"
    width="1200" height="600" loading="eager">
    </section>
    <!-- Services Preview, CTA, Footer sections... -->
    <script src="/js/main.js" defer></script>
    </body>
    </html>
  4. Create public/_headers

    /*
    X-Frame-Options: SAMEORIGIN
    X-Content-Type-Options: nosniff
    Referrer-Policy: strict-origin-when-cross-origin
    Strict-Transport-Security: max-age=31536000; includeSubDomains
    Cache-Control: public, max-age=3600
    /css/*
    Cache-Control: public, max-age=31536000, immutable
    /js/*
    Cache-Control: public, max-age=31536000, immutable
    /images/*
    Cache-Control: public, max-age=31536000, immutable
    /fonts/*
    Cache-Control: public, max-age=31536000, immutable
  5. Create public/404.html and .gitignore

    <!DOCTYPE html>
    <html lang="en">
    <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Page Not Found | Bay Area Dermatology</title>
    <link rel="stylesheet" href="/css/styles.css">
    </head>
    <body>
    <main class="error-page">
    <h1>404 - Page Not Found</h1>
    <p>The page you're looking for doesn't exist or has been moved.</p>
    <a href="/" class="btn btn-primary">Return to Homepage</a>
    </main>
    </body>
    </html>

    .gitignore:

    node_modules/
    .env
    .wrangler/
    *.DS_Store
  6. Set up npm scripts and start developing

    {
    "scripts": {
    "dev": "wrangler pages dev public",
    "deploy": "wrangler pages deploy public",
    "preview": "npx serve public -p 4173"
    }
    }
    Terminal window
    npx wrangler pages dev public
    # Server runs at http://localhost:8788

Forms require two pieces: a client-side handler and a Cloudflare Function.

public/js/form-handler.js:

document.addEventListener('DOMContentLoaded', function () {
const form = document.getElementById('contact-form');
if (!form) return;
form.addEventListener('submit', async function (e) {
e.preventDefault();
const submitButton = form.querySelector('button[type="submit"]');
const originalText = submitButton.textContent;
submitButton.textContent = 'Sending...';
submitButton.disabled = true;
try {
const formData = new FormData(form);
formData.append('form_source', 'Contact Page');
const response = await fetch('/api/submit-form', {
method: 'POST',
body: formData,
});
const result = await response.json();
if (result.success) {
form.innerHTML = `
<div class="form-success">
<h3>Thank You!</h3>
<p>${result.message}</p>
</div>
`;
} else {
alert(result.message || 'Something went wrong. Please try again.');
submitButton.textContent = originalText;
submitButton.disabled = false;
}
} catch (error) {
alert('Network error. Please try again or call us directly.');
submitButton.textContent = originalText;
submitButton.disabled = false;
}
});
});

This is the primary section. Astro is the recommended path for any site with 5+ pages, a blog, or regular content updates.

Content Collections

Define structured schemas for services, blog posts, team members. Markdown files for each, validated at build time.

Component Islands

Interactive elements (maps, booking widgets, galleries) load only when needed. Static content ships zero JS.

Image Optimization

Automatic WebP conversion, responsive sizes, lazy loading. Critical for image-heavy practice sites.

Layouts

Define header/footer once, reuse across all pages. Change once, update everywhere.

Static by Default

Pages are pre-built HTML. Fastest possible load times. Perfect Lighthouse scores achievable.

CMS Integration

Decap CMS gives non-technical staff a browser-based editor for content.

Terminal window
# Create new Astro project
npm create astro@latest my-practice-site
# During setup, choose:
# Template: Empty (recommended - we'll build from scratch)
# TypeScript: Yes (Strict)
# Install dependencies: Yes
# Initialize git: Yes
cd my-practice-site
# Add Cloudflare adapter
npx astro add cloudflare
# Verify it works
npm run dev
# Opens at http://localhost:4321
my-practice-site/
├── astro.config.mjs # Astro configuration
├── package.json
├── tsconfig.json
├── public/ # Static assets (copied as-is)
│ ├── favicon.ico
│ ├── robots.txt
│ └── fonts/
├── src/
│ ├── assets/ # Optimized assets (processed by Astro)
│ │ └── images/
│ ├── components/ # Reusable UI components
│ │ ├── Header.astro
│ │ ├── Footer.astro
│ │ ├── ServiceCard.astro
│ │ ├── ContactForm.astro
│ │ ├── GoogleMap.astro
│ │ └── SEO.astro
│ ├── content/ # Content Collections (Markdown)
│ │ ├── config.ts # Collection schemas
│ │ ├── services/
│ │ ├── blog/
│ │ └── team/
│ ├── layouts/ # Page layouts (shared structure)
│ │ ├── BaseLayout.astro
│ │ ├── PageLayout.astro
│ │ └── BlogLayout.astro
│ ├── pages/ # Routes (each file = a URL)
│ │ ├── index.astro # → /
│ │ ├── about.astro # → /about/
│ │ ├── contact.astro # → /contact/
│ │ ├── services/
│ │ │ ├── index.astro # → /services/
│ │ │ └── [slug].astro # → /services/botox/, etc.
│ │ ├── blog/
│ │ │ ├── index.astro # → /blog/
│ │ │ └── [slug].astro # → /blog/post-slug/, etc.
│ │ └── 404.astro # → Custom 404
│ └── styles/
│ └── global.css
├── functions/ # Cloudflare Functions
│ └── api/
│ └── submit-form.js
└── .env # Environment variables (not committed)

astro.config.mjs:

import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';
export default defineConfig({
output: 'static', // Pre-build all pages (recommended)
adapter: cloudflare(),
site: 'https://www.bayareadermatology.com',
image: {
service: {
entrypoint: 'astro/assets/services/sharp',
},
},
vite: {
build: {
cssMinify: true,
},
},
});

Static vs SSR output modes:

ModeConfigUse When
output: 'static'Pre-builds all pages at deploy timeMost practice sites. Best performance.
output: 'server'Renders pages on each request via WorkersDynamic content, personalization, auth.
output: 'hybrid'Static by default, opt-in SSR per pageStatic site with a few dynamic pages.
---
import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
import SEO from '../components/SEO.astro';
import '../styles/global.css';
interface Props {
title: string;
description: string;
image?: string;
canonicalURL?: string;
}
const { title, description, image, canonicalURL } = Astro.props;
const siteName = 'Bay Area Dermatology';
const fullTitle = `${title} | ${siteName}`;
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<SEO title={fullTitle} description={description} image={image}
canonicalURL={canonicalURL || Astro.url.href} siteName={siteName} />
<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin>
<link rel="icon" href="/favicon.ico">
</head>
<body>
<Header />
<main>
<slot />
</main>
<Footer />
</body>
</html>
---
interface Props {
title: string;
description: string;
image?: string;
canonicalURL: string;
siteName: string;
type?: string;
}
const { title, description, image = '/images/og-default.jpg', canonicalURL, siteName, type = 'website' } = Astro.props;
---
<title>{title}</title>
<meta name="description" content={description}>
<link rel="canonical" href={canonicalURL}>
<meta property="og:title" content={title}>
<meta property="og:description" content={description}>
<meta property="og:type" content={type}>
<meta property="og:url" content={canonicalURL}>
<meta property="og:image" content={image}>
<meta property="og:site_name" content={siteName}>
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content={title}>
<meta name="twitter:description" content={description}>
<meta name="twitter:image" content={image}>

Content Collections let you define structured content in Markdown files with validated schemas.

src/content/config.ts:

import { defineCollection, z } from 'astro:content';
const services = defineCollection({
type: 'content',
schema: ({ image }) => z.object({
title: z.string(),
description: z.string(),
image: image(),
order: z.number(),
category: z.enum(['medical', 'cosmetic', 'surgical']),
duration: z.string().optional(),
price: z.string().optional(),
featured: z.boolean().default(false),
}),
});
const blog = defineCollection({
type: 'content',
schema: ({ image }) => z.object({
title: z.string(),
description: z.string(),
pubDate: z.date(),
updatedDate: z.date().optional(),
image: image().optional(),
author: z.string().default('Bay Area Dermatology'),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
});
const team = defineCollection({
type: 'content',
schema: ({ image }) => z.object({
name: z.string(),
title: z.string(),
image: image(),
credentials: z.array(z.string()),
specialties: z.array(z.string()),
order: z.number(),
}),
});
export const collections = { services, blog, team };

Example service: src/content/services/botox.md:

---
title: "Botox Cosmetic"
description: "FDA-approved treatment to reduce fine lines and wrinkles."
image: "../../assets/images/services/botox.jpg"
order: 1
category: "cosmetic"
duration: "15-30 minutes"
price: "Starting at $12/unit"
featured: true
---
## What is Botox?
Botox Cosmetic is an FDA-approved injectable treatment that temporarily
reduces the appearance of moderate to severe frown lines, crow's feet,
and forehead lines in adults.
## What to Expect
- **Consultation:** We'll discuss your goals and develop a treatment plan
- **Treatment:** Quick injections with a fine needle (15-30 minutes)
- **Recovery:** No downtime. Return to normal activities immediately
- **Results:** Visible within 3-7 days, lasting 3-4 months

src/pages/services/[slug].astro — Generates a page for each service:

---
import { getCollection } from 'astro:content';
import PageLayout from '../../layouts/PageLayout.astro';
import ContactForm from '../../components/ContactForm.astro';
export async function getStaticPaths() {
const services = await getCollection('services');
return services.map(service => ({
params: { slug: service.slug },
props: { service },
}));
}
const { service } = Astro.props;
const { Content } = await service.render();
---
<PageLayout title={service.data.title} description={service.data.description}
heroTitle={service.data.title} heroImage={service.data.image}>
<div class="service-detail">
<div class="service-meta">
{service.data.duration && <span>Duration: {service.data.duration}</span>}
{service.data.price && <span>{service.data.price}</span>}
</div>
<div class="service-content"><Content /></div>
<div class="service-cta">
<h2>Interested in {service.data.title}?</h2>
<ContactForm formSource={`Service: ${service.data.title}`} />
</div>
</div>
</PageLayout>

Astro’s built-in image optimization converts images to WebP, generates responsive sizes, and lazy-loads automatically.

---
import { Image } from 'astro:assets';
import heroImage from '../assets/images/hero.jpg';
---
<!-- Astro automatically: converts to WebP, generates srcset, sets dimensions -->
<Image src={heroImage} alt="Modern dermatology office" width={1200} height={600} loading="eager" />

Decap CMS (formerly Netlify CMS) provides a browser-based editing interface for non-technical users.

flowchart LR
    A[Staff logs into /admin/] --> B[Makes changes via form UI]
    B --> C[Clicks Publish]
    C --> D[Decap CMS commits to GitHub]
    D --> E[Cloudflare Pages auto-rebuilds]
    E --> F[Site live in ~60 seconds]

Create public/admin/index.html and public/admin/config.yml to configure Decap CMS with your content collections (services, blog, team). After deploying, visit https://yoursite.com/admin/ to access the editing interface.

Terminal window
# Build
npm run build
# Output goes to dist/
# Deploy manually
npx wrangler pages deploy dist
# Or add to package.json:
# "deploy": "npm run build && wrangler pages deploy dist"

Common Patterns for Medical Practice Sites

Section titled “Common Patterns for Medical Practice Sites”
PageURLPurpose
Homepage/Hero, featured services, testimonials, CTA
Services/services/All services categorized
Service Detail/services/botox/Individual treatment page
About/about/Practice story, mission, values
Our Team/team/Providers with credentials
Blog/blog/Educational content, SEO
Blog Post/blog/winter-skincare/Individual article
Contact/contact/Form, map, hours, directions
Before/After/gallery/Treatment results (optional)
404Any invalid URLCustom error page

Contact Forms

Cloudflare Functions + SendGrid for email delivery. Same pattern for both Plain HTML and Astro.

Google Maps

Embed via <iframe> with Google Maps Embed API. Lazy-load for performance.

Booking Widget

Embed Calendly, Acuity, or Cal.com via <iframe>. Third-party handles everything.

Analytics

Direct GA4/GTM script tags. Use is:inline in Astro to prevent bundling.

Image Gallery

Before/After galleries with Astro’s <Image> component and CSS Grid.

Social Links

Static link components. No JavaScript needed.


Terminal window
cd my-practice-site
git init
git config user.email "admin@example.com"
git config user.name "admin"
git add .
git commit -m "Initial site build"
gh repo create my-practice-site --private --source=. --push

Connect to Cloudflare Pages for Auto-Deploy

Section titled “Connect to Cloudflare Pages for Auto-Deploy”
SettingValue
Framework presetNone
Build command(leave empty)
Build output directorypublic
Root directory/
flowchart TD
    A[Developer pushes to main] --> B[Cloudflare Pages detects push]
    B --> C[Runs build command]
    C --> D[Deploys to global CDN]
    D --> E[Site live in ~60 seconds]

    F[Developer pushes to branch] --> G[Cloudflare creates preview deployment]
    G --> H["Preview URL: branch.project.pages.dev"]
    H --> I[Team reviews before merging]

StepTimeCommand
1. Create project5 minmkdir + files
2. Build pages2-8 hoursWrite HTML/CSS
3. Add forms30 minform-handler.js + submit-form.js
4. Test locally15 minnpx wrangler pages dev public
5. Push to GitHub5 mingit push
6. Connect to Cloudflare10 minDashboard setup
7. Custom domain10 minDNS configuration
Total3-9 hours

Both approaches result in a fully deployed, globally distributed website at $0/month hosting cost.


While Astro is the recommended path, other frameworks also deploy to Cloudflare Pages:

FrameworkAdapterBest ForNotes
Next.js@cloudflare/next-on-pagesReact apps, complex interactivityHeavier, more JS shipped
Nuxtnitro-preset-cloudflare-pagesVue appsGood Vue ecosystem
SvelteKit@sveltejs/adapter-cloudflareSvelte apps, performance-focusedLightweight, fast
Remix@remix-run/cloudflare-pagesFull-stack React appsStrong data loading patterns
QwikBuilt-in Cloudflare adapterUltra-performance appsResumability instead of hydration