Building swill.fun with Next.js and Cloudflare Workers
End-to-end record of building and deploying swill.fun — a personal site powered by Next.js 16 + Cloudflare Workers. Covers deployment setup, SEO configuration, and gotchas encountered along the way.
Tech Stack
- Framework: Next.js 16.2.2 (App Router) + React 19
- Content: Velite + MDX + Shiki
- Styling: Tailwind CSS 4
- Deployment: Cloudflare Workers +
@opennextjs/cloudflare - Package Manager: Bun 1.3
Deployment
Why Workers Over Pages
@opennextjs/cloudflareofficially targets Workers; Pages is no longer the primary path- Generous free tier: static asset requests don't count, 100K dynamic requests/day
- SEO capabilities identical to Pages (SSR / SSG / ISR / metadata all supported)
- Cost: effectively $0 for small-to-medium sites
Key Configuration Files
wrangler.toml
name = "swill-fun"
main = ".open-next/worker.js"
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]
[assets]
directory = ".open-next/assets"
binding = "ASSETS"
[vars]
NEXT_PUBLIC_URL = "https://swill.fun"open-next.config.ts
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
export default defineCloudflareConfig();next.config.ts — key settings
const nextConfig: NextConfig = {
turbopack: {},
serverExternalPackages: ["shiki"], // required, otherwise build fails
images: {
formats: ["image/avif", "image/webp"],
},
};package.json — build scripts
{
"scripts": {
"build": "velite build && next build",
"build:cf": "bun run build && opennextjs-cloudflare build",
"preview": "bun run build:cf && opennextjs-cloudflare preview",
"deploy": "bun run build:cf && opennextjs-cloudflare deploy"
}
}Cloudflare Dashboard Settings
Path: Workers & Pages → Create → Import a repository
| Field | Value |
|---|---|
| Build command | bun run build:cf |
| Deploy command | bunx opennextjs-cloudflare deploy |
| Root directory | leave empty |
| Production branch | main |
Environment variables (required for both Build and Runtime):
NEXT_PUBLIC_URL=https://swill.fun
Gotchas
1. Build and Deploy Commands Must Be Separate
bun run build only runs velite build && next build — it does NOT produce the .open-next/ directory. You must also run opennextjs-cloudflare build, otherwise the deploy step fails with:
ERROR Could not find compiled Open Next config, did you run the build command?
Fix: Add a build:cf script in package.json that chains both the Next.js build and the OpenNext build.
2. Don't Mix Pages and Workers Config in wrangler.toml
pages_build_output_diris a Pages-only field — Workers deployment ignores it- Workers requires
main+[assets]block - An empty
[[kv_namespaces]]block causes an error — remove it entirely if unused
3. shiki Must Be Externalized
Add serverExternalPackages: ["shiki"] in next.config.ts. Note that Next.js 16 renamed experimental.serverComponentsExternalPackages to serverExternalPackages.
4. Browser 308 Redirect Cache Trap
If the domain previously had a permanent redirect (301/308), the browser caches it locally even after you delete the rule from Cloudflare. To verify the actual server response:
curl -I https://swill.funcurl has no cache — it returns the real server response. Even incognito mode in browsers may not be reliable.
5. Edge Redirect Rules Take Priority Over Workers
If response headers show no cf-worker field, only server: cloudflare, the request never reached the Worker — it was intercepted at the edge. Check these in order:
- Rules → Redirect Rules
- Account-level Bulk Redirects
- Rules → Page Rules (legacy)
- Workers Routes
SEO Setup
Two Essential Files
app/sitemap.ts — dynamic sitemap generation
import type { MetadataRoute } from 'next'
import { posts } from '#site/content'
export default function sitemap(): MetadataRoute.Sitemap {
const baseUrl = 'https://swill.fun'
const postUrls = posts.map((post) => ({
url: `${baseUrl}/posts/${post.slug}`,
lastModified: new Date(post.date),
changeFrequency: 'monthly' as const,
priority: 0.7,
}))
return [
{
url: baseUrl,
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 1,
},
...postUrls,
]
}app/robots.ts
import type { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: '*',
allow: '/',
disallow: ['/api/'],
},
sitemap: 'https://swill.fun/sitemap.xml',
}
}Root Layout Metadata
export const metadata: Metadata = {
metadataBase: new URL('https://swill.fun'),
title: {
default: 'Swill',
template: '%s | Swill',
},
description: 'Site description, 50-160 chars',
openGraph: {
title: 'Swill',
description: 'Same as above',
url: 'https://swill.fun',
siteName: 'Swill',
locale: 'zh_CN',
type: 'website',
},
twitter: {
card: 'summary_large_image',
},
robots: {
index: true,
follow: true,
},
}Per-Post Metadata
export async function generateMetadata({ params }): Promise<Metadata> {
const post = posts.find(p => p.slug === params.slug)
return {
title: post.title,
description: post.description,
openGraph: {
type: 'article',
publishedTime: post.date,
images: [`/og?title=${encodeURIComponent(post.title)}`],
},
}
}Search Engine Submission
-
Google Search Console (https://search.google.com/search-console)
- Add property
https://swill.fun - Verify ownership via DNS TXT record
- Submit
https://swill.fun/sitemap.xml
- Add property
-
Bing Webmaster Tools (https://www.bing.com/webmasters)
- Can import directly from Google Search Console
SEO Timeline Expectations
- New site from scratch: 2-8 weeks before first appearance in search results
- 3-6 months for stable rankings
- Brand terms (e.g. "swill.fun") typically rank #1 within 1-2 weeks
- Competitive generic terms take longer — prioritize long-tail keywords
Edge Runtime Constraints
Routes using export const runtime = "edge" (e.g. /og):
- No Node.js-specific APIs (
fs,path, etc.) - No large npm packages (Worker size limit: 3 MB compressed)
- No static generation (build warns "disables static generation" — this is expected)
Local Development
# Dev mode (Velite watch + Next dev)
bun run dev
# Preview Cloudflare build locally
bun run preview
# Deploy to Cloudflare
bun run deploy