Swill
← Back

Building swill.fun with Next.js and Cloudflare Workers

·Updated April 9, 2026·5 min read·

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/cloudflare officially 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_dir is 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.fun

curl 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:

  1. Rules → Redirect Rules
  2. Account-level Bulk Redirects
  3. Rules → Page Rules (legacy)
  4. 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

  1. 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
  2. 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

References