· 9 min read
Why I Moved My SvelteKit Site From Vercel to Cloudflare
My site paid $30 a month for cold starts it didn't need. How I prerendered every page, moved to Cloudflare Workers, and cut time to first byte to 183 ms.
By Tanner Goins
Vercel is a great platform, and deploying this site to it was as easy as pushing to GitHub. Last month I moved the site to Cloudflare Workers anyway. Vercel didn’t do anything wrong. I was using it for a job my site didn’t need done.
This is how I figured that out, what the move involved, what broke along the way, and the moment I took my own site down.
The symptom: slow first visits
Vercel’s Speed Insights gave my blog posts and contact page a Real Experience Score between 36 and 39 out of 100. Time to first byte at the 75th percentile was 4.5 seconds. For a site made of markdown files, that’s embarrassing.
The cause was cold starts. Every page was rendered on request by a serverless function, and 66% of those invocations were cold. A cold visit waited 2 to 10 seconds before the server sent anything. Even robots.txt took 1.2 seconds. A cached page answered in 0.11 to 0.18 seconds, but that rarely helped: with a few visits a day and several deploys a day, most visits to most posts were the first request after a deploy.
Fixing it on Vercel first
My first instinct was to fix it where it was. I turned on incremental static regeneration (ISR), so pages were cached on Vercel’s CDN and rebuilt at most once an hour. That kept scheduled posts working: the blog hides posts dated in the future, and the hourly refresh made each one appear on its date without a deploy.
Every deploy still started with an empty cache, so I added a script that ran after each production deploy and requested every URL in the sitemap once, so real visitors wouldn’t pay for the cold start. I also turned on Fluid compute, which keeps warm instances around for the renders that were left.
Both helped. Both were also workarounds. I was writing code to warm a cache for pages that never changed between deploys.
My site barely needs a server
Every page on this site is markdown compiled at build time. There’s no database, no login, and nothing personalized. The only things that need to run code on a request are the two contact forms, the newsletter signup, one redirect that forwards its query string, and the 404 page. I’ve written about choosing between SSR and static generation in SvelteKit, and by that standard my own site was on the wrong side of the line.
Meanwhile, I was paying $30 a month: $20 for Vercel Pro, which I needed because the consulting side of the site is commercial, and $10 for Speed Insights. My actual infrastructure usage was about a cent of the $20 that Pro includes.
I was also already running Cloudflare in front of Vercel, to manage bot traffic and keep Vercel’s page views (and my bill) down. That meant every visit crossed two networks, which Vercel itself recommends against, because its firewall can’t see the traffic behind the proxy.
So I stopped trying to make Vercel work and asked a different question: what if most of the site didn’t run on a server at all?
Prerendering SvelteKit on Cloudflare Workers
On Cloudflare, every page is prerendered at build time and uploaded as a static file. Cloudflare serves those files before any code runs, so a page view costs no function invocation and no CPU time, and has no cold start. A small Worker handles only the routes that have to run per request.
| What | Where it runs |
|---|---|
| Every page, the sitemap, RSS, robots.txt, social cards | Static files, served by Cloudflare |
| The contact forms and newsletter signup | The Worker |
/promo, which forwards UTM tags | The Worker |
| Unknown URLs (the 404 page) | The Worker |
With SvelteKit’s Cloudflare adapter, the switch was mostly one line per layout. The ISR config became:
// src/routes/(main)/+layout.ts
export const prerender = true; The routes that have to stay dynamic opt out with prerender = false.
The Worker’s config is short. The adapter writes the Worker and the static files into .svelte-kit/cloudflare, and Wrangler deploys both:
{
"name": "tannergoins",
"main": ".svelte-kit/cloudflare/_worker.js",
"compatibility_date": "2026-10-01",
"compatibility_flags": ["nodejs_compat"],
// Prerendered pages and assets are served before the Worker runs.
"assets": { "binding": "ASSETS", "directory": ".svelte-kit/cloudflare" },
"observability": { "enabled": true }
} Deploys moved from Vercel’s Git integration to one GitHub Actions workflow. A push to master checks, tests, builds, and runs wrangler deploy. A nightly rebuild at 00:05 UTC publishes scheduled posts, since there’s no hourly refresh anymore. Each pull request gets its own private preview URL, protected by Cloudflare Access.
SvelteKit gotchas on Cloudflare Workers
Prerendering everything sounds simple. Most of the work was in the edges.
Hooks don’t run on static files. A server hook added an X-Robots-Tag: noindex header to my presentation decks, so search engines would skip them. A prerendered page never runs that hook, so the header would have silently disappeared. Headers like that now live in a _headers file:
/presentations/*
X-Robots-Tag: noindex
/rss.xml
Content-Type: application/rss+xml; charset=utf-8 The second rule is there because a static file’s content type comes from its extension, so the RSS feed would otherwise be served as plain XML. The file has to sit in the project root: the Cloudflare adapter rejects one in static/, because it appends its own rules for the /_app/ assets.
A secret was baked into the build. My email API key was imported from $env/static/private, which inlines the value into the server bundle at build time, so every build needed the key and every build output carried it. On Cloudflare the key is a Worker secret, set once with wrangler secret put and read on each request:
import { env } from '$env/dynamic/private';
await new Resend(env.RESEND_API_KEY).emails.send(msg); (I’ve since upgraded to SvelteKit 3, where that import is $app/env/private.)
Local runs must not send email. The contact form used to check process.env.NODE_ENV to avoid sending real email during development. A Worker doesn’t set that the way Vercel did, so the form now checks SvelteKit’s dev flag or a localhost hostname. That also covers a production build served locally with vite preview or wrangler dev.
Pages nothing links to don’t get built. SvelteKit finds pages to prerender by crawling links from the home page. When I merge an old post into a newer one, the old URL redirects, but nothing links to it anymore, so it was never built and its redirect would have vanished. The post route now lists those old slugs explicitly:
export const entries: EntryGenerator = () =>
Object.keys(MOVED_POSTS).map((post) => ({ post })); The adapter turns each one into a rule in _redirects.
A redirect that needs its query string. The landing page for my ads, /promo, forwards its UTM tags and click IDs to the real page. A prerendered redirect can’t read the query string, so /promo stays a Worker route.
Scheduled posts changed. Under ISR, a post dated for tomorrow appeared within an hour of midnight. Now it appears at the nightly rebuild, and its URL returns a 404 until then: an unpublished post isn’t linked from anywhere, so it isn’t built.
The 27 GB build. This one surprised me. Each post gets a social card painted at build time with @napi-rs/canvas. Version 1.0.9 leaked about a megabyte of memory on every pattern fill, which added up to around 600 MB per card. A build on GitHub Actions with an empty cache peaked at 27 GB, and the runner was killed partway through prerendering. Version 1.0.11 fixed the leak, and a cold build now peaks near 2 GB.
Proving nothing changed
The goal was for search engines to notice nothing. Before switching DNS, a temporary script fetched every URL in the sitemap from both Vercel and the Worker’s workers.dev address, then compared the status code, title, meta description, canonical URL, <h1>, structured data, robots header, and content type. Any difference blocked the cutover.
I also moved my build’s SEO check to run against the built Worker in workerd, Cloudflare’s runtime, through Wrangler’s test harness. It crawls every internal link and fails the build on broken links, missing images, bad structured data, or pages without a proper title and description. Running it in workerd means it checks what Cloudflare will actually serve, _headers and _redirects included.
The cutover
The plan was a calm, 15-minute switch with an instant rollback. Then, while cleaning up DNS records, I deleted the www record that pointed at Vercel. The bare domain redirects to www, so the whole site went down.
Oops.
The fix was quick. I attached www.tannergoins.com to the Worker as a custom domain, and the site was back within minutes. Some DNS resolvers had cached the missing record, and for them it took up to the zone’s 30-minute negative cache to recover.
Then I cancelled Vercel Pro. Vercel’s free Hobby plan doesn’t allow commercial use, so the moment Pro was gone, so was my rollback. That made the move final a little earlier than I’d planned, but by then every check against the live site had passed.
Doing it with AI
I did this migration with Claude Code, and I started with a plan, not code. The plan was a document in the repo: the current architecture, the target, every behavior that would change, every environment variable and where it would live, the risks, and seven phases from swapping the adapter to shutting down Vercel, each with its own checklist. Then we worked through it phase by phase.
It took a couple of days. Without AI, it would have taken much longer. Writing the plan first is what I’d recommend either way: it caught problems before they happened, like the /promo query string and the change to scheduled posts.
Results: time to first byte from 4.5 s to 183 ms
Cost: from $30 a month to $5. I’m on Cloudflare’s $5 Workers Paid plan, and Cloudflare Web Analytics, which reports Core Web Vitals from real visitors, is free.
Speed: here’s a day of real visits, November 1 to 2, from Cloudflare Web Analytics:
| Metric | Result |
|---|---|
| Time to first byte (75th percentile) | 183 ms, down from 4.5 s |
| Largest Contentful Paint (75th percentile) | 292 ms (168 ms median) |
| Pages with a good LCP, INP, and CLS | 100% |
The before number came from Vercel’s Speed Insights and the after numbers from Cloudflare, and a day of a small site’s traffic is a small sample. But both measure real visitors, and 4.5 seconds against 183 milliseconds isn’t a difference that measurement quirks explain.
PageSpeed Insights agrees. The day after the move, its desktop test gave the home page 100 in all four categories (performance, accessibility, best practices, and SEO), with the first content painted at 0.5 seconds, the largest at 0.6 seconds, no blocking time, and no layout shift.
Simplicity: static files are about as boring as hosting gets, and that’s the point. There’s no cache to warm or purge, and no cold start to tune. A deploy replaces every file.
Should you do this?
Vercel is great, just not the right tool for this job. If your app renders per user, leans on Next.js features that Vercel supports first, or your team values its workflow, it’s worth what it costs. If your site is content compiled at build time, ask how much of it actually needs a server. For mine, the answer was a contact form.