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
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:
What this means in practice:
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]
| Criteria | Plain HTML | Astro | Next.js / Nuxt / SvelteKit |
|---|---|---|---|
| Best for | Landing pages, 1-5 page sites | Marketing sites, blogs, 5-50+ pages | Web applications, heavy interactivity |
| Build step | None | Yes (npm run build) | Yes |
| Learning curve | Minimal | Low-medium | Medium-high |
| Component reuse | Manual copy/paste | Built-in components, layouts | Built-in |
| Blog support | Manual HTML per post | Content Collections (Markdown/MDX) | Varies |
| Image optimization | Manual (external tools) | Built-in (astro:assets) | Framework-dependent |
| CMS integration | Manual | First-class (Decap, Tina, etc.) | Varies |
| Cloudflare integration | Basic (static files) | First-party (acquired) | Adapter required |
| AI editability | Direct HTML editing | Component + Markdown editing | Varies |
| Setup time | 5 minutes | 10 minutes | 15-30 minutes |
| Typical build time | 0 seconds | 2-10 seconds | 10-60 seconds |
Plain HTML
Use when:
Astro (Recommended)
Use when:
Other Frameworks
Use when:
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)Create the project
mkdir my-practice-sitecd my-practice-site
npm init -ynpm install wrangler --save-dev
mkdir -p public/css public/js public/images public/fontsmkdir -p functions/apiCreate wrangler.toml
name = "my-practice-site"compatibility_date = "2024-01-01"pages_build_output_dir = "public"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>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, immutableCreate 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_StoreSet up npm scripts and start developing
{ "scripts": { "dev": "wrangler pages dev public", "deploy": "wrangler pages deploy public", "preview": "npx serve public -p 4173" }}npx wrangler pages dev public# Server runs at http://localhost:8788Forms 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; } });});functions/api/submit-form.js:
export async function onRequestPost(context) { const { request, env } = context;
const SENDGRID_API_KEY = env.SENDGRID_API_KEY; const RECIPIENT_EMAILS = ['info@bayareadermatology.com']; const FROM_EMAIL = 'noreply@bayareadermatology.com'; const FROM_NAME = 'Bay Area Dermatology Website';
try { const formData = await request.formData(); const name = formData.get('name') || 'Not provided'; const email = formData.get('email') || 'Not provided'; const phone = formData.get('phone') || 'Not provided'; const message = formData.get('message') || 'Not provided'; const formSource = formData.get('form_source') || 'Contact Form';
const isDev = request.url.includes('localhost') || request.url.includes('127.0.0.1');
if (isDev) { console.log('DEV MODE - Would send email:', { name, email, phone, message }); } else { if (!SENDGRID_API_KEY) throw new Error('SENDGRID_API_KEY not configured');
const sgResponse = await fetch('https://api.sendgrid.com/v3/mail/send', { method: 'POST', headers: { 'Authorization': `Bearer ${SENDGRID_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ personalizations: [{ to: RECIPIENT_EMAILS.map(e => ({ email: e })), subject: `New ${formSource} - ${name}` }], from: { email: FROM_EMAIL, name: FROM_NAME }, reply_to: { email: email !== 'Not provided' ? email : FROM_EMAIL }, content: [{ type: 'text/html', value: `<h2>New ${formSource}</h2><p><strong>Name:</strong> ${name}</p><p><strong>Email:</strong> ${email}</p><p><strong>Phone:</strong> ${phone}</p><p><strong>Message:</strong> ${message}</p>` }], }), });
if (!sgResponse.ok) throw new Error('Failed to send email'); }
return new Response(JSON.stringify({ success: true, message: 'Thank you! We will contact you within 24 hours.' }), { status: 200, headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }, }); } catch (error) { console.error('Form error:', error); return new Response(JSON.stringify({ success: false, message: 'Sorry, there was an error. Please call us directly.' }), { status: 500, headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' }, }); }}
export async function onRequestOptions() { return new Response(null, { status: 204, headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type' }, });}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.
# Create new Astro projectnpm 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 adapternpx astro add cloudflare
# Verify it worksnpm run dev# Opens at http://localhost:4321my-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:
| Mode | Config | Use When |
|---|---|---|
output: 'static' | Pre-builds all pages at deploy time | Most practice sites. Best performance. |
output: 'server' | Renders pages on each request via Workers | Dynamic content, personalization, auth. |
output: 'hybrid' | Static by default, opt-in SSR per page | Static 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>---import BaseLayout from './BaseLayout.astro';import { Image } from 'astro:assets';
interface Props { title: string; description: string; heroTitle?: string; heroSubtitle?: string; heroImage?: ImageMetadata;}
const { title, description, heroTitle, heroSubtitle, heroImage } = Astro.props;---
<BaseLayout title={title} description={description}> {heroTitle && ( <section class="hero"> <div class="hero-content"> <h1>{heroTitle}</h1> {heroSubtitle && <p class="hero-subtitle">{heroSubtitle}</p>} </div> {heroImage && ( <Image src={heroImage} alt={heroTitle} width={1200} height={600} loading="eager" class="hero-image" /> )} </section> )} <div class="page-content"> <slot /> </div></BaseLayout>---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}>---import { Image } from 'astro:assets';
interface Props { title: string; description: string; image: ImageMetadata; href: string;}
const { title, description, image, href } = Astro.props;---
<a href={href} class="service-card"> <Image src={image} alt={title} width={400} height={300} loading="lazy" class="service-card-image" /> <div class="service-card-content"> <h3>{title}</h3> <p>{description}</p> <span class="service-card-link">Learn More</span> </div></a>
<style> .service-card { display: block; text-decoration: none; color: inherit; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); transition: transform 0.2s, box-shadow 0.2s; } .service-card:hover { transform: translateY(-4px); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); } .service-card-content { padding: 1.5rem; } .service-card-link { color: var(--color-primary); font-weight: 600; }</style>---interface Props { formSource?: string; }const { formSource = 'Contact Page' } = Astro.props;---
<form id="contact-form" class="contact-form"> <input type="hidden" name="form_source" value={formSource}> <div class="form-group"> <label for="name">Full Name *</label> <input type="text" id="name" name="name" required> </div> <div class="form-group"> <label for="email">Email Address *</label> <input type="email" id="email" name="email" required> </div> <div class="form-group"> <label for="phone">Phone Number</label> <input type="tel" id="phone" name="phone"> </div> <div class="form-group"> <label for="message">How can we help you?</label> <textarea id="message" name="message" rows="4"></textarea> </div> <button type="submit" class="btn btn-primary">Send Message</button></form>
<div id="form-success" class="form-success" style="display:none;"> <h3>Thank You!</h3> <p>We will contact you within 24 hours.</p></div>
<script> const form = document.getElementById('contact-form') as HTMLFormElement; const successEl = document.getElementById('form-success');
form?.addEventListener('submit', async (e) => { e.preventDefault(); const btn = form.querySelector('button[type="submit"]') as HTMLButtonElement; const originalText = btn.textContent; btn.textContent = 'Sending...'; btn.disabled = true;
try { const formData = new FormData(form); const response = await fetch('/api/submit-form', { method: 'POST', body: formData }); const result = await response.json(); if (result.success) { form.style.display = 'none'; if (successEl) successEl.style.display = 'block'; } else { alert(result.message || 'Something went wrong.'); btn.textContent = originalText; btn.disabled = false; } } catch { alert('Network error. Please call us directly.'); btn.textContent = originalText; btn.disabled = false; } });</script>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: 1category: "cosmetic"duration: "15-30 minutes"price: "Starting at $12/unit"featured: true---
## What is Botox?
Botox Cosmetic is an FDA-approved injectable treatment that temporarilyreduces 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 monthssrc/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.
# Buildnpm run build# Output goes to dist/
# Deploy manuallynpx wrangler pages deploy dist
# Or add to package.json:# "deploy": "npm run build && wrangler pages deploy dist"| Page | URL | Purpose |
|---|---|---|
| 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) |
| 404 | Any invalid URL | Custom 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.
cd my-practice-sitegit initgit 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| Setting | Value |
|---|---|
| Framework preset | None |
| Build command | (leave empty) |
| Build output directory | public |
| Root directory | / |
| Setting | Value |
|---|---|
| Framework preset | Astro |
| Build command | npm run build |
| Build output directory | dist |
| Root directory | / |
| Node.js version | 18 |
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]
| Step | Time | Command |
|---|---|---|
| 1. Create project | 5 min | mkdir + files |
| 2. Build pages | 2-8 hours | Write HTML/CSS |
| 3. Add forms | 30 min | form-handler.js + submit-form.js |
| 4. Test locally | 15 min | npx wrangler pages dev public |
| 5. Push to GitHub | 5 min | git push |
| 6. Connect to Cloudflare | 10 min | Dashboard setup |
| 7. Custom domain | 10 min | DNS configuration |
| Total | 3-9 hours |
| Step | Time | Command |
|---|---|---|
| 1. Create Astro project | 10 min | npm create astro@latest |
| 2. Build layouts + components | 2-4 hours | Astro components |
| 3. Create content (services, team) | 1-2 hours | Markdown files |
| 4. Add forms | 30 min | ContactForm + Function |
| 5. Add CMS (optional) | 30 min | Decap CMS config |
| 6. Test locally | 15 min | npm run dev |
| 7. Push to GitHub | 5 min | git push |
| 8. Connect to Cloudflare | 10 min | Dashboard setup |
| 9. Custom domain | 10 min | DNS configuration |
| Total | 4-8 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:
| Framework | Adapter | Best For | Notes |
|---|---|---|---|
| Next.js | @cloudflare/next-on-pages | React apps, complex interactivity | Heavier, more JS shipped |
| Nuxt | nitro-preset-cloudflare-pages | Vue apps | Good Vue ecosystem |
| SvelteKit | @sveltejs/adapter-cloudflare | Svelte apps, performance-focused | Lightweight, fast |
| Remix | @remix-run/cloudflare-pages | Full-stack React apps | Strong data loading patterns |
| Qwik | Built-in Cloudflare adapter | Ultra-performance apps | Resumability instead of hydration |