Migrating from Netlify to Cloudflare Pages: What the Switch Actually Involves
Netlify is a solid platform. It handles deployments cleanly, the DX is good, and for simple static sites it stays out of your way. But once you’re already using Cloudflare for DNS and CDN — which most people are — running Netlify alongside it adds a vendor, adds latency, and adds cost once bandwidth climbs.
We migrated several Astro sites to Cloudflare Pages. The main reasons: Cloudflare Functions average 1–10ms cold starts versus Netlify Functions at around 200ms, the free tier includes unlimited bandwidth, and consolidating onto one platform simplifies everything from DNS management to secret rotation.
Here’s what the migration actually involves — including the parts that aren’t obvious.
The right order if you’re moving multiple sites
If you’re migrating more than one site, don’t do them all at once. The pattern that works:
- Move the simplest static site first — no functions, pure HTML. This validates the Cloudflare Pages setup.
- Move sites with one function next.
- Move the most complex site last — multiple functions, payment flows, external integrations.
Test each site thoroughly before touching the next. Rollback is easy (change a DNS record), but diagnosing two simultaneous migrations is not.
Email infrastructure: do this first
This is the part Netlify users most often miss. Netlify Functions can use SMTP directly — nodemailer, any SMTP host. Cloudflare Workers cannot. The Workers runtime doesn’t support TCP connections, which SMTP requires.
You need to switch to an HTTP-based email API before you migrate. MailChannels works for free if you’re sending from Cloudflare Workers. Maileroo, Resend, and Postmark all work equally well.
The code change is straightforward:
// Netlify (nodemailer)
const transporter = nodemailer.createTransport({ host: 'smtp.example.com', ... });
// Cloudflare (HTTP API)
await fetch('https://smtp.maileroo.com/send', {
method: 'POST',
headers: { 'X-API-Key': env.MAILEROO_API_KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ from: '...', to: '...', subject: '...', html: '...' }),
});
If using MailChannels, add these DNS records to your domain before testing:
# SPF — add to existing record
v=spf1 include:_spf.mx.cloudflare.net include:YOUR_EXISTING_PROVIDER ~all
# Domain lockdown (prevents spoofing)
Type: TXT | Name: _mailchannels | Content: v=mc1; cfp=2
Allow 5–10 minutes for DNS propagation before testing sends.
File structure and handler syntax
Netlify Functions live in netlify/functions/. Cloudflare Functions live in functions/ at the project root. The URL paths stay the same, so the frontend code doesn’t change — only the function files move and the handler syntax changes.
# Netlify
netlify/functions/contact-form.js → exposed at /api/contact-form
# Cloudflare
functions/api/contact-form.js → exposed at /api/contact-form
The handler itself is different:
// Netlify
exports.handler = async (event, context) => {
return { statusCode: 200, body: JSON.stringify({ ok: true }) };
};
// Cloudflare
export async function onRequestPost(context) {
const { request, env } = context;
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
Environment variable access also changes: process.env.MY_VAR becomes context.env.MY_VAR. Public Astro env variables (import.meta.env.PUBLIC_VAR) work the same on both platforms.
Stripe webhooks need a URL update
If you’re running Stripe payments, the webhook URL changes after migration. Update it in the Stripe Dashboard before going live:
- Old:
https://yourdomain.com/.netlify/functions/stripe-webhook - New:
https://yourdomain.com/api/stripe-webhook
Go to Stripe → Developers → Webhooks, edit the endpoint URL, and verify the webhook secret still matches your STRIPE_WEBHOOK_SECRET environment variable. Test with Stripe’s built-in webhook test tool before declaring the migration done.
Rollback plan
Keep your Netlify sites live for 7–14 days after migration. To roll back any site: change the CNAME in Cloudflare DNS back to xxx.netlify.app, and if you have Stripe webhooks, change the URL back too. That’s it — 2-minute rollback.
This is why you migrate one site at a time. If the most complex site has an issue, the others are already stable on Cloudflare and you’ve only got one thing to debug.
After 14 days of stable operation
Once everything is confirmed working:
- Delete the Netlify sites from the dashboard
- Remove
netlify/directories from repositories - Remove
netlify.tomlfiles - Remove
netlify-clifrom dev dependencies if present
The move is worth making. Cloudflare’s edge network is faster, the pricing model is simpler, and having DNS, CDN, and deployment in one place reduces the number of dashboards you need to check when something breaks.
Want to learn more?
See how we set up and operate GDPR-compliant self-hosted infrastructure at a fraction of SaaS costs.
Learn more