Tanstack Start + Cloudflare 部署指南

2026-05-07

我是一个 Next.js 的爱好者,我的很多项目包括本博客都是基于 Next.js 开发的。在开发 Next.js 过程中我觉得主要有两点让我不满意:

基于这些原因我决定研究更轻量级的框架,它就是 Tanstack Start。它是基于 @tanstack/react-routerVite 之上的全栈框架,他轻量、执行模型明确、可以部署在任何云服务平台上。

由此我选择的核心基础框架和方案就是:

下面我就一步步从 0 到 1 来分享一下我是怎么实现的,其中包括本地开发环境,Pre 环境 和 Prod 环境的部署上线

目标受众

由于这不是一篇讲解全栈开发的教程,所以本博重点在于如何实现本地开发和部署到 Cloudflare,所以这边博客是为有一定经验的朋友准备的。如果你完全不具备全栈开发经验也可以跟着一步一步操作,这也是一个学习过程。

开发前的准备

在开发前你需要准备一个 Cloudflare 账号备用,后期需要部署时的授权。

我还需要介绍一下 Better-T-Stack 它是一个用于创建项目基础结构的一个 CLI 工具,可以为我们提供一个良好的目录结构。接下来我们就使用它来初始化一个项目吧。

初始化项目

首先运行命令 pnpm create better-t-stack@latest 初始化一个项目,接下来有一个选项。我的选择是:

  Enter your project name or path (relative to current directory)
  happy-tanstack-cf

  Select project type
  Web

  Choose web
  TanStack Start

  Select backend
  Self (Fullstack)

  Select database
  SQLite

  Select ORM
  Drizzle

  Select API type
  None

  Select authentication provider
  Better-Auth

  Select payments provider
  None

  Select addons
  Biome, Husky

  Include examples
  none

  Select sqlite setup option
  Cloudflare D1

  Initialize git repository?
  No

  Choose package manager
  pnpm

  Install dependencies?
  No

当上面代码执行完毕以后,你会得到这样一个目录结构:

.
├── apps
│   └── web           # 应用主程序
│       ├── public    # 公共资源
│       └── src
│           ├── components
│           ├── functions   # API 函数
│           ├── lib
│           ├── middleware  # 中间件
│           └── routes      # 页面和 API 路由
│               └── api
└── packages
    ├── auth    # Better Auth 登录模块
    │   └── src
    ├── config  # 项目基础配置
    ├── db      # 数据库模块
    │   └── src
    │       └── schema
    ├── env     # 环境变量模块
    │   └── src
    ├── infra   # 基础设置模块
    └── ui      # 基于 Shadcn 组件的 UI 模块
        └── src
            ├── components
            ├── hooks
            ├── lib
            └── styles

这里值得一提的是 infra 模块,它是一个基于 Alchemy 框架的将基础设施抽象成代码的框架。所有的 Cloudflare 资源创建和释放都将是通过代码自动化来完成,期间不需要我们手动操作。

运行本地开发环境

现在运行命令 pnpm i 去安装我们的项目依赖文件。

执行 pnpm dev 启动本地开发环境,浏览器访问 http://localhost:3001 即可看到界面。

pnpm 追加日志

我开发中一般会运行 pnpm -reporter=append-only dev 让 CLI 的日志不折叠这样方便查看日志

看到这样的日志就证明我们的服务启动成功:

packages/infra dev:   VITE v8.0.11  ready in 3554 ms
packages/infra dev:  Local:   http://localhost:3001/
packages/infra dev:  Network: use --host to expose
packages/infra dev:  Debug:   http://localhost:3001/__debug
packages/infra dev: [updating]  web Updating Resource...
packages/infra dev: [updated]   web Updated Resource
packages/infra dev: Web    -> http://localhost:3001/

Alchemy 模拟环境

启动服务后我们可以在根目录看到 .alchemy 文件夹,这是 Alchemy 为我们的模拟环境,它包含了我们的基础设施资源。

.alchemy
└── miniflare
  └── v3
      ├── cache
      │   └── miniflare-CacheObject
      ├── d1
      │   └── miniflare-D1DatabaseObject
      ├── do
      └── workflows

它主要是会为我们模拟一个本地的 SQLite 数据库。

数据库

因为我们要部署到 Cloudflare D1 所以我这里选择了 SQLite 数据库。因为我们选择了 Better-Auth 作为登录验证,所以我们可以查看 packages/db/src/schema/auth.ts 文件这就是 Betteru-Auth 的基础 Schema 文件。通过这个文件我们就可以帮我们的数据库表和字段迁移到 SQLite 数据库中。

现在我们执行 pnpm run db:generate 就可以自动生成数据库表和字段。

随便找一个数据库管理软件,我使用的是 SQLiteStudio 打开 .alchemy/miniflare/v3/d1/miniflare-D1DatabaseObject/3d4ffb7927e8eeadb0c3bb4dfa75c351d1eab010615de505483c732d2b67801c.sqlite,你的数据库名字可能会不一样但是一定在 miniflare-D1DatabaseObject 目录下。就可以查看到我们已经迁移好的表和数据库了。

数据库结构

如果你需要新增或修改 Schema 则可以在 packages/db/src/schema 中新增文件,像 auth.ts 一样,然后再次执行 pnpm run db:generate 就可以自动生成数据库表和字段。

测试程序正确性

现在我们访问 http://localhost:3001/login 来注册一个账号验证程序是否正确。当你看到如下界面的时候代表基础程序一切正常运行。

正常界面

部署到 Cloudflare

当程序运行正常后需要尽快部署到 Cloudflare 上,因为部署的越晚程序越部署调试的难度会加大。

授权 Cloudflare

我们使用了 alchemy 框架所以我们需要先执行 pnpm alchemy configure 然后来授权 Cloudflare 的访问权限。

接下来会让你输入一个 Profile name,我们保持默认的 defualt 就可以了,按回车就会跳转授权。

你只需要登录 Cloudflare 跟着操作就可以了。

确定部署环境

目前我想部署两个环境分别是: Pre 和 Prod,Pre 是预发布环境用来验证程序的正确性最后再发布到 Prod 环境上。

下面就来配置环境,Prod 环境和 Pre 环境其实是一样的配置过程。

创建 env 文件

创建 packages/infra/.env.prepackages/infra/.env.prod 文件内容暂时为空,请注意所有 .env 文件都不要提交到 Git 仓库中。

然后我们将 apps/web/.env 中的内容放到 packages/infra/.env.prepackages/infra/.env.prod 文件中。

然后将 BETTER_AUTH_URL 和 CORS_ORIGIN 修改为实际网址,如果你没有线上的域名那么可以使用 worker.dev 的默认域名。

比如你 Cloudflare 登录的邮箱为: [email protected],那么为你生成的 URL 就是:https://happy-tanstack-cf-web-pre.abc-net.workers.dev

最终配置就是这样的:

# pre 配置
BETTER_AUTH_SECRET=4MrV6HA2C5vHmVYAhMZpsCunVTllC8aT
BETTER_AUTH_URL=https://happy-tanstack-cf-web-pre.abc-net.workers.dev
CORS_ORIGIN=https://happy-tanstack-cf-web-pre.abc-net.workers.dev
# prod 配置
BETTER_AUTH_SECRET=4MrV6HA2C5vHmVYAhMZpsCunVTllC8aT
BETTER_AUTH_URL=https://happy-tanstack-cf-web-prod.abc-net.workers.dev
CORS_ORIGIN=https://happy-tanstack-cf-web-prod.abc-net.workers.dev

创建部署命令

首先在根目录中的 package.json 新增如下两个命令,分别对应 Pre 和 Prod 的发布命令

{
  "scripts": {
    "release:pre": "pnpm --filter @happy-tanstack-cf/infra release:pre",
    "release:prod": "pnpm --filter @happy-tanstack-cf/infra release:prod"
  }
}

然后在 packages/infra/package.json 中新增如下两个命令:

{
  "scripts": {
    "release:pre": "alchemy deploy --stage pre --env-file .env.pre",
    "release:prod": "alchemy deploy --stage prod --env-file .env.prod"
  }
}

alchemy deploy 会在 Cloudflare 创建 Worker、D1、编译打包代码、上传到 Cloudflare 上。

开始部署

完成以上步骤后,执行 pnpm release:pre 就可以开始部署 Pre 环境了。

等待一会看到如下界面代表部署成功,可以访问这个地址并且验证功能,如果有错误请根据上面步骤仔细检查。

部署成功

测试注册是否正确

测试 Pre 注册

发了了什么

我们一步步操作服务就这样在开发环境和 Pre 环境运行起来了。但是这背后到底发生了什么?

答案就是在 apps/web/dist/server/wrangler.json 文件中,它是 Cloudflare 的 Wrangler 配置文件,在这个配置文件中我们可以定义需要在 Cloudflare 上部创建的资源然后绑定到我们项目。同时这些资源会注入到 Worker 中的 Env 中,这样我们就可以在代码中调用对应的服务。

比如我们要调用数据库操作,我们可以查看 packages/db/src/index.ts 文件,代码是这样的:

import { env } from "@happy-tanstack-cf/env/server";
import { drizzle } from "drizzle-orm/d1";
import * as schema from "./schema";

export function createDb() {
  return drizzle(env.DB, { schema });
}

这里 env.DB 中的 DB 就是来之 wrangler.json 文件中的绑定资源名称。查看 wrangler.json 文件可以验证这一点:

下面 d1_databases.binding 就是要注入到 Env 中名称,你也可以改成别的名称。

{
  "topLevelName": "happy-tanstack-cf-web-pre",
  "vars": {
    "CORS_ORIGIN": "https://happy-tanstack-cf-web-pre.abc-net.workers.dev",
    "BETTER_AUTH_SECRET": "4MrV6HA2C5vHmVYAhMZpsCunVTllC8aT",
    "BETTER_AUTH_URL": "https://happy-tanstack-cf-web-pre.abc-net.workers.dev"
  },
  "r2_buckets": [],
  "d1_databases": [
    {
      "binding": "DB",
      "database_id": "775286b6-eb64-40c0-8784-6bd1bd1086fa",
      "database_name": "happy-tanstack-cf-database-pre",
      "migrations_dir": "../../packages/db/src/migrations",
      "preview_database_id": "775286b6-eb64-40c0-8784-6bd1bd1086fa"
    }
  ]
}

但是上面的 wrangler.json 文件只是现象,到底是什么会导致生成 d1_databases 这样的配置呢?

答案就在 packages/infra/alchemy.run.ts 文件中的 D1Database 对象的创建:

import alchemy from "alchemy";
import { D1Database, TanStackStart } from "alchemy/cloudflare";
import { config } from "dotenv";

config({ path: "./.env" });
config({ path: "../../apps/web/.env" });

const app = await alchemy("happy-tanstack-cf");

// 数据库资源创建
const db = await D1Database("database", {
  migrationsDir: "../../packages/db/src/migrations",
});

export const web = await TanStackStart("web", {
  cwd: "../../apps/web",
  bindings: {
    DB: db,
    CORS_ORIGIN: alchemy.env.CORS_ORIGIN!,
    BETTER_AUTH_SECRET: alchemy.secret.env.BETTER_AUTH_SECRET!,
    BETTER_AUTH_URL: alchemy.env.BETTER_AUTH_URL!,
  },
});

console.log(`Web    -> ${web.url}`);

await app.finalize();

我们可以看到 alchemy.run.ts 文件中有这样一段代码:

// 创建资源
const db = await D1Database("database", {
  migrationsDir: "../../packages/db/src/migrations",
});
export const web = await TanStackStart("web", {
  cwd: "../../apps/web",
  bindings: {
    DB: db, // 绑定到 Env 中的名称
    CORS_ORIGIN: alchemy.env.CORS_ORIGIN!,
    BETTER_AUTH_SECRET: alchemy.secret.env.BETTER_AUTH_SECRET!,
    BETTER_AUTH_URL: alchemy.env.BETTER_AUTH_URL!,
  },
});

它首先创建了一个数据库资源,然后将其绑定到 bindings 中,在 bindings 中我们可以定义我们需要注入到 Env 中的名称。

这样在你编译的时候就是生成对应的 wrangler.json 配置文件然后发布到 Cloudflare 上,一切就是这样工作起来的。

环境变量

环境变量有两种类型一种为保密环境变量,另一种为公开环境变量。

export const web = await TanStackStart("web", {
  cwd: "../../apps/web",
  bindings: {
    DB: db, // 绑定到 Env 中的名称
    CORS_ORIGIN: alchemy.env.CORS_ORIGIN!,
    BETTER_AUTH_SECRET: alchemy.secret.env.BETTER_AUTH_SECRET!,
    BETTER_AUTH_URL: alchemy.env.BETTER_AUTH_URL!,
  },
});

上面我们使用了 alchemy.env.CORS_ORIGIN 公开环境变量,它会在 Cloudflare 中以明文显示。

alchemy.secret.env.BETTER_AUTH_SECRET 这种为非公开环境变量,它会在 Cloudflare 中以加密显示,部署以后在 Cloudflare 我们无法看到其真实的值。

你可以根据你的需要设置不同的环境变量类型

增加 Cloudflare Cron 定时任务

好了,现在大概理清了一些基本的运行原理,现在我们继续为项目增加一个定时任务。定时任务是一种在程序中非常常见的功能,比如每天早上十点后发送邮件。

下面我们就来创建一个定时任务,它会在每天早上十点后发送一封邮件。

为了实现定时任务,首先我们要定义定时任务执行的时间,在 packages/infra/alchemy.run.ts 中定义定时任务 Cron 表达:

// 省略上面的代码
export const web = await TanStackStart("web", {
  cwd: "../../apps/web",
  bindings: {
    DB: db,
    CORS_ORIGIN: alchemy.env.CORS_ORIGIN!,
    BETTER_AUTH_SECRET: alchemy.secret.env.BETTER_AUTH_SECRET!,
    BETTER_AUTH_URL: alchemy.env.BETTER_AUTH_URL!,
  },
  // 这里定义定时任务执行时间
  crons: ["* * * * *"], //["0 10 * * *"],
});
// 省略下面的代码

定义完毕定时任务后,我们需要定义定时任务接收的代码。根据 wrangler 官方文档可以看到需要在 server.ts 中定义一个名为 scheduled 的函数。一旦有定时任务达到会自动执行。

我们添 apps/web/src/server.ts 文件定义如下代码:

import handler from "@tanstack/react-start/server-entry";
import { sendMail } from "./functions/send-mail";

export default {
  fetch(request: Request) {
    return handler.fetch(request);
  },
  async scheduled(
    event: ScheduledEvent,
    env: Env,
    _ctx: ExecutionContext
  ): Promise<void> {
    if (event.cron === "0 10 * * *") {
      console.log("开始发送电子邮件");
      await sendMail();
    }
  },
};

这里我解释一下 fetch 函数是程序的主入口代码,可以定义一些中间件和国际化函数处理,其他方法可以参考 wrangler 文档。

然后我们实现这个模拟邮件发送 sendMail 方法:

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export async function sendMail() {
  // TODO: 模拟发送电子邮件
  await sleep(1000);
  console.log("电子邮件已发送");
}

再次启动服务等待结果定时任务并没有触发,这是因为在开发环境定时任务不会自动触发需要手动执行:

http://localhost:3001/cdn-cgi/handler/scheduled?cron=0+10+*+*+*

才会在开发环境触发定时任务。

如果是部署到 Cloudflare 就会自动触发。

真实的邮件发送

要使用 Cloudflare 发送电子邮件,首先需要按照 文档,初始化服务。

然后根据 Alchemy文档 我们需要配置 EmailSender 和绑定到 Env 中。

首先我们再次进入 packages/infra/alchemy.run.ts 文件,新建一个 EmailSender 对象:

// 发送邮件配置
const emailSendConfig = EmailSender({
  allowedSenderAddresses: ["[email protected]"],
  // dev: { remote: true }, // 如果你想在开发环境发送真实的邮件请打开它
});

然后我们只需要将 emailSendConfig 对象绑定到 Env 中即可:

export const web = await TanStackStart("web", {
  bindings: {
    EMAIL: emailSendConfig,
    // 省略其他代码
  },
});

现在我们来将之前使用 sendMail 方法的代码替换成如下代码:

import { env } from "@happy-tanstack-cf/env/server";

export async function sendMail(to: string) {
  await env.EMAIL.send({
    to,
    from: "[email protected]",
    subject: "测试邮件abc",
    html: "<h1>测试邮件abc</h1>",
  });
}

然后更新定时任务重的函数调用,传递一个 email 的接收地址:

export default {
  async scheduled(
    event: ScheduledEvent,
    env: Env,
    _ctx: ExecutionContext
  ): Promise<void> {
    if (event.cron === "0 10 * * *") {
      console.log("开始发送电子邮件");
      await sendMail("[email protected]"); // 传递一个接收邮件地址
    }
  },
};

现在我们手动运行定时任务 http://localhost:3001/cdn-cgi/handler/scheduled?cron=0+10+*+*+* 去触发邮件的发送,查看控制台日志有如下输出:

packages/infra dev: 开始发送电子邮件
packages/infra dev: send_email binding called with MessageBuilder:
packages/infra dev: From: [email protected]
packages/infra dev: To: [email protected]
packages/infra dev: Subject: 测试邮件abc
packages/infra dev: HTML: /var/folders/kr/mdb4d54j1m176s8c2z1t70y80000gn/T/miniflare-75e51ec333560116240eda8a14429b8e/email/email-html/52d0ff65-0694-47d3-9f8b-f5e2722a5285.html

上面的 HTML 地址就是我们邮件发送的 HTML,你可以用浏览器打开查看是否正确。

找不到 EmailSender 导出成员错误

如果你的代码提示模块“"alchemy/cloudflare"”没有导出的成员“EmailSender”,请更新 pnpm-workspace.yaml 中的 alchemy 版本到 ^0.93.7, 重新执行 pnpm i 即可。

Queue 队列任务

现在我们来实现队列任务,由于队列需要有单独的名称,首先我们要区分 Pre 和 Prod 环境的队列名称。

现在我们需在 alchemy.run.ts 文件定义定义一个 stage 变量用于获取我们编译时传递的环境。

const stage = process.env.STAGE ?? "dev";

然后需要创建一个 队列名称 + stage 的队列名称:

const queueName = `example-queue-${stage}`;

接下来需要创建消息队列对象:

// 处理消息队列配置;
const exampleQueue = await Queue(queueName, {
  name: queueName,
});

最后将名称和消息队列绑定到 TanStackStart 对象上,完整的代码如下:

import alchemy from "alchemy";
import {
  D1Database,
  EmailSender,
  Queue,
  TanStackStart,
} from "alchemy/cloudflare";
import { config } from "dotenv";

// 省略代码

const stage = process.env.STAGE ?? "dev";
const queueName = `example-queue-${stage}`;
// 处理消息队列配置;
const exampleQueue = await Queue(queueName, {
  name: queueName,
});
export const web = await TanStackStart("web", {
  // 省略的代码
  bindings: {
    EXAMPLE_QUEUE: exampleQueue,
  },
  eventSources: [
    {
      name: queueName,
      queue: exampleQueue,
      settings: {
        batchSize: 4,
        maxConcurrency: 3,
        maxRetries: 5,
        maxWaitTimeMs: 2000,
        retryDelay: 30,
      },
    },
  ],
});

console.log(`Web    -> ${web.url}`);

await app.finalize();

队列任务定义完毕,接下来我们需要在 apps/web/src/server.ts 中定义队列处理函数,代码如下:

import handler from "@tanstack/react-start/server-entry";
import { sendMail } from "./functions/send-mail";

export default {
  // 省略代码
  async queue(batch: MessageBatch<{ id: string }>, _env: Env): Promise<void> {
    batch.messages.map(async (msg) => {
      console.log("执行一些异步处理任务", msg.body.id);
      msg.ack(); // 告诉队列执行完成
    });
  },
};

好了,队列处理任务模拟完成。现在我们在定义任务 Cron 中去创建队列任务:

import handler from "@tanstack/react-start/server-entry";
import { sendMail } from "./functions/send-mail";

export default {
  fetch(request: Request) {
    return handler.fetch(request);
  },
  async scheduled(
    event: ScheduledEvent,
    env: Env,
    _ctx: ExecutionContext
  ): Promise<void> {
    console.log("event", event.cron);
    if (event.cron === "0 10 * * *") {
      console.log("开始发送电子邮件");
      await sendMail("[email protected]");
    } else if (event.cron === "0 20 * * *") {
      // 创建队列任务
      await env.EXAMPLE_QUEUE.send({ id: 123 });
    }
  },
  async queue(batch: MessageBatch<{ id: string }>, _env: Env): Promise<void> {
    batch.messages.map(async (msg) => {
      console.log("执行一些异步处理任务", msg.body.id);
      msg.ack(); // 告诉队列执行完成
    });
  },
};

现在我们在浏览器中访问:http://localhost:3001/cdn-cgi/handler/scheduled?cron=0+20+*+*+* 去触发我们定时任务创建一个队列任务

然后查看控制台日志,可以看到创建了一个队列任务,并且执行了一个异步任务。

 [vite] hot updated: virtual:cloudflare/worker-entry
 event 0 20 * * *
 执行一些异步处理任务 undefined
 QUEUE example-queue-dev 1/1 (4ms)

好了队列任务的创建和执行已经介绍完毕,如果你想了解更对关于队列任务的参数定义和文档,请访问这个地址

部署状态持久化

由于之前我们部署过,现在你可以查看 apps/web/dist/server/wrangler.json 文件,可以看到其中有:

{
  "d1_databases": [
    {
      "binding": "DB",
      "database_id": "64a40142-f6b8-4ba8-9dad-fa9b00aa4f4e",
      "database_name": "happy-tanstack-cf-database-pre",
      "migrations_dir": "../../packages/db/src/migrations",
      "preview_database_id": "64a40142-f6b8-4ba8-9dad-fa9b00aa4f4e"
    }
  ]
}

这里的 d1_databases 就是我们刚刚定义的数据库资源,它会自动在部署到 Cloudflare 上的时候被创建。由于这些数据我们是不会提交到 Git,所有一旦我们更换了电脑那么重新部署将会重新创建这些数据库。但是 Cloudflare 上又存在同名的数据库这个时候部署就会失败。

这个的解决方案就是使用 CloudflareStateStore 将我们的部署配置存储到 Cloudflare Durable Object 上去。这样即便我们更换电脑,配置文件也会在云上就可以找到我们的数据库资源。

配置方法如下:

import alchemy from "alchemy";
import {
  D1Database,
  EmailSender,
  Queue,
  TanStackStart,
} from "alchemy/cloudflare";
import { CloudflareStateStore } from "alchemy/state";
import { config } from "dotenv";

config({ path: "./.env" });
config({ path: "../../apps/web/.env" });

const stage = process.env.STAGE ?? "dev";
// alchemy 新增一个参数,传入我们的 Store 配置对象
const app = await alchemy("happy-tanstack-cf", {
  stage,
  stateStore:
    stage !== "dev"
      ? (scope) =>
          new CloudflareStateStore(scope, {
            scriptName: `launly-app-state-${stage}`,
          })
      : undefined,
});
const queueName = `example-queue-${stage}`;

上面我又做了一下环境区分,如果是开发环境使用本地生成的配置,只有是 Pre 或 Prod 才存储配置到 Durable Object 上去。这样即便我们更换电脑,配置文件也会在云上就可以找到我们的数据库资源。

现在运行命令 pnpm release:prod 就可以开始部署了,如果不刚好换了电脑又没有这些配置文件。最简单的办法就是把 Cloudflare 上的 Worker 和 D1 删了重新部署一次即可。下一次部署就没问题了。

总结

我们使用了 Tansack Start 和 Vite 框架作为我们的业务层,然后使用 Alchemy 将我们的 Cron、Email、Queue 等项目需要的功能抽象成了代码对象去自动管理资源。这样我们就可以更多的关心业务层面而不必关心基础设置,专心完成业务逻辑提供更好的软件功能。

最后附上 Github 地址以便参考。