2025-05-15 08:00:00
关注我的社交媒体的朋友都知道,最近我发布了一个 Next.js SaaS 全栈模板,因为开发工作太多了,导致我都没怎么系统地介绍模板,今天突然有灵感,边吃饭边码字,写下这篇 Nexty.dev 模板介绍。
在3月份,吉卜力风格的AI图片爆火的时候,我决定启动独立开发计划里的「AI图片站」。
因为观察了很多同类的产品,所以很早就有一套大致的思路,比如功能模块怎么设计、SEO内容怎么规划、启动宣传怎么进行。
但是看一看我做过的开源启动模板,比如 SaaS模板、落地页模板、博客模板、还有长期维护并且当作核心开源项目的多语言模板,它们都只能应对单一场景,无法替我解决我大脑里构思的一切。也就是这时候,我决定先搭建一个通用的全栈SaaS模板,然后再上站。
让我没预料到的是,做这个新模板越做越上头,功能越加越多,做AI图片站也变得没那么大吸引力了。这个新模板就是Nexty.dev ,它也由此变成我当前的主要项目了。
在做模板的时候,一方面为了自己备忘,另一方面希望让客户了解产品的全部模块,我还做了Roadmap,把自己的开发计划公开。
这样做的好处是信息透明,客户做决策没有心理负担,至少不用担心拿到的成品不符合自己的预期。
说回「越做越上头」。因为出发点是给自己做一套可立即使用的模板,所以内置的功能越做越细。简单列几个:
登录无需赘言,对于面向海外的产品,Google授权和邮箱登录是必备的。因为我经常面向开发者做点东西,所以还加了GitHub授权。
邮箱登录其实有一个风险,例如Gmail、outlook都支持邮箱别名,用户可能通过邮箱别名注册新账号,如果你的网站有给新用户免费额度,那么很容易被白嫖。为了解决这个问题,Nexty.dev 模板内置了邮箱别名、临时邮箱的检测和过滤方法,降低被白嫖的风险。
同类模板里,支付模块一般都是提供核心流程的代码,但是我是照着自己的需求做基础设施,做着做着需求越加越多,支付模块直接内置了完整的一次性付款和周期订阅付款加积分、周期订阅续订更新积分、退款清除积分的的完整流程,并且提供配合使用的自定义示例代码。
整个支付模块把所有通用的东西都做出来,并且把需要自己结合业务逻辑自定义的部分标注出来,让模板使用者更清楚如何快速打通完整流程。真正的开箱即用。
如果只是如此,也够得上“有诚意”的评价。但是我觉得把定价信息放在多语言 JSON 文件管理是很麻烦的一件事,所以又给自己加需求了——在管理后台实现定价卡片的动态管理。这种实现方式是同类模板里绝无仅有的。
动态管理定价卡片的设计也是花了心思,不仅所有卡片上的内容可以从服务端获取,还可以区分环境,避免环境混乱把开发环境的测试数据放在生产环境;还可以直接从 Stripe 拉取定价信息,这种方式最大的好处是不会因为手动操作填错信息;而且给卡片添加了多语言翻译,前端直接根据页面语言展示管理后台翻译的结果,人工只需要审核翻译文案,其他问题根本不用操心。
无图言……,所以还是得上图:
列表展示
编辑页面,支持选择卡片存在的环境、排序、是否展示(激活);Stripe集成的部分,一键获取准确的信息,不需要手动多次复制数据
特性展示也是全部服务端配置
支持多语言翻译
支持自定义复杂的自定义权益
完成数据编辑后,用户端实时同步,就是下面这样:
这是我最满意的功能之一,因为有了这个功能,更新定价的时候,我不需要再打开代码,而是直接在管理后台界面操作,一分钟就能完成数据更新。
这个模块内置了文本、图片、视频多种的 AI 功能调用方式。目的有两个:其一是,模板使用者(包括我)可以在新模型出来的时候,最快速度启动测试,验证模型API是否可用,其二是,很多人想做AI功能但是没有那么多精力学习,那么他购买了模板后就可以根据 AI Demo 快速学习AI调用流程。
其他同类模板一般只是提供封装好的 API,不会为模板提供这样的 AI Demo,只有 Nexty.dev 这么做。因为我开发的时候是以模版使用者的角度来审视是否符合我的需求。
Nexty.dev 的 CMS 模块是奔着既能做多语言博客,又能做付费 newsletter 去设计的。
除了基本的博客必备信息(标题、slug、描述、标签、封面图),我还增加了高级功能,例如设置置顶、编辑状态(草稿、发布、归档)、访问权限(公开、登录用户、订阅用户),连正文都同时支持富文本和markdown格式,而且支持 AI 翻译。
除此之外,列表页还支持复制博客来创建新博客。复制功能的使用场景是,想要快速创建多语言博客,直接复制已有语言的博客版本,进入编辑页后,修改语言选项、再翻译一下标题、描述和正文,就能快速发布不同语言的博客版本,把多语言内容生产效率拉满!
这个模板原计划1-2周完成,最后愣是做了一个月,而且用户提的新需求还在开发,计划内的一些小需求也在开发。这让我原本考虑做 AI 图片站的计划只能推迟。
模板现在还有一些优化功能正在开发,所以价格还是早鸟价,如果你正在寻找好用的模板,不妨试试 Nexty.dev。现在的价格就是历史最低价,等功能和文档逐步完善后,会涨到和竞品同级别的定价。
等模板工作完成后,我也会使用这套模板开始自己新的产品计划,还会有更多经验分享,也会给模板新增更多有用的通用功能,希望大家继续关注~。
🧑💻独立开发|⛵️出海|Next.js手艺人
🖥️做过开源:http://github.com/weijunext
⌨️写过博客:https://weijunext.com
🛠️今年想做独立产品和课程
📙Next.js SaaS 全栈模板:https://nexty.dev/zh
📙Next.js 全栈教程:https://ship.weijunext.com
📘Nextjs中文文档:http://nextjscn.org
欢迎在以下平台关注我:
2025-03-20 08:00:00
看多了英文网站,你会发现大多数网站都提供了 newsletter 订阅功能,也就是邮件订阅。因为因为海外用户其实都还习惯查看邮箱,所以如果我们的网站有邮件订阅功能,只要有用户订阅了邮件,我们就可以离用户更近一些。
当我知道利用 CloudFlare 和 Resend 可以低成本开发邮件订阅功能的时候,我就决定给我的自用Next.js 模板补充一下这个功能。
根据本文的步骤,你可以10分钟完成整个接入流程。不过需要提醒的是,本文教学的是接入的基础步骤,如果你的产品用户量大、安全需求高,你需要在此基础上,结合自己的业务添加额外的防范措施,这一点也会在文末提出一点我的看法。
Cloudflare 是一家提供内容分发网络(CDN)、DDoS 防护、安全服务和边缘计算解决方案的全球性公司,帮助网站提高性能、安全性和可靠性。因为其慷慨的免费服务而被称为“赛博菩萨”。
本文的流程里,我们首先需要把域名放在 CloudFlare 解析
然后打开邮件功能
创建邮箱转发
继续
根据提示自动添加 DNS 记录,
完成后如图
现在发送到 [email protected]
的邮件就会被 CloudFlare 转发到我指定的邮箱了。
Resend 是一个现代化的电子邮件 API 平台,使开发者能够轻松地将高质量的电子邮件功能集成到应用中,提供可靠的邮件发送、跟踪和分析服务。
注册地址:https://resend.com/
这里跳过注册步骤。
进入 Resend 后台,先添加域名
输入要添加的域名后,直接点击「Sign in to CloudFlare」按钮,会自动添加 DNS 记录。
现在前3个记录已经自动添加了,还需要我们手动添加 _dmarc
记录。
回到 CloudFlare DNS,添加 _dmarc
记录,值为 v=DMARC1; p= quarantine;
如果 Resend 很久了还是现实 pending 状态,不要担心,这一步总是要等很久,先继续做下面的步骤
先来创建 API Key,权限选择 Full Access,创建完成后,复制下 API Key
再打开 Audience,复制 Audience ID
现在打开项目,在 .env
文件里添加 3 个环境变量
RESEND_API_KEY=
ADMIN_EMAIL=
RESEND_AUDIENCE_ID=
其中,ADMIN_EMAIL 是你的 Resend 账户邮箱。
本节只提供实现思路,并留下完整代码的 GitHub 地址,需要完整代码可以自取。
首先,我们需要一个用户友好的订阅表单。以下是核心实现:
// components/footer/Newsletter.tsx
// 完整代码地址:https://github.com/weijunext/nextjs-15-starter/blob/main/components/footer/Newsletter.tsx
"use client";
export function Newsletter() {
// 状态管理:邮箱、订阅状态和错误信息
const [email, setEmail] = useState("");
const [subscribeStatus, setSubscribeStatus] = useState("idle");
const [errorMessage, setErrorMessage] = useState("");
const handleSubscribe = async (e) => {
try {
// 设置加载状态
setSubscribeStatus("loading");
// API调用发送订阅请求
const response = await fetch("/api/newsletter", {
method: "POST",
body: JSON.stringify({ email }),
// 设置headers...
});
// 处理响应...
// 5秒后重置状态...
} catch (error) {
// 错误处理...
// 5秒后重置状态...
}
};
return (
<div>
<form onSubmit={handleSubscribe}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={subscribeStatus === "loading"}
/>
<button disabled={subscribeStatus === "loading"}>
{subscribeStatus === "loading" ? "订阅中..." : "订阅"}
</button>
{/* 状态反馈信息 */}
{subscribeStatus === "success" && <p>订阅成功!</p>}
{subscribeStatus === "error" && <p>{errorMessage}</p>}
</form>
</div>
);
}
这个组件需要实现:
为了确保邮箱地址的有效性,我实现了两个关键函数:
// lib/email.ts
// 完整代码地址:https://github.com/weijunext/nextjs-15-starter/blob/main/lib/email.ts
function validateEmail(email: string) {
// 验证邮箱格式
// 检查域名长度
// 检查一次性邮箱
// 检查特殊字符
}
function normalizeEmail(email: string) {
// 标准化处理
// 处理别名 (如 Gmail 的点号和加号后缀)
}
这两个方法创建了一些常见的验证,例如邮箱格式、防止一次性邮箱、处理邮箱别名等,如果你有更多有用的验证方法,可以很方便地进行扩展。
在服务器端,我们需要处理订阅请求:
// app/api/newsletter/route.ts
// 完整代码地址:https://github.com/weijunext/nextjs-15-starter/blob/main/app/api/newsletter/route.ts
import { normalizeEmail, validateEmail } from '@/lib/email';
import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
import { Resend } from 'resend';
// 初始化 Resend
const resend = new Resend(process.env.RESEND_API_KEY);
// Resend Audience ID
const AUDIENCE_ID = process.env.RESEND_AUDIENCE_ID!;
export async function POST(request: Request) {
try {
// 处理请求数据
const { email } = await request.json();
const normalizedEmail = normalizeEmail(email);
// 验证邮箱……
// 生成退订令牌和链接
const unsubscribeToken = Buffer.from(normalizedEmail).toString('base64');
const unsubscribeLink = `${process.env.NEXT_PUBLIC_SITE_URL}/unsubscribe?token=${unsubscribeToken}`;
// 检查用户是否已存在
const list = await resend.contacts.list({ audienceId: AUDIENCE_ID });
if (list.data?.data.find((item) => item.email === normalizedEmail)) {
return NextResponse.json({ success: true, alreadySubscribed: true });
}
// 将用户添加到 Resend Audience
await resend.contacts.create({
audienceId: AUDIENCE_ID,
email: normalizedEmail,
// 注释: 可添加更多用户信息
});
// 发送欢迎邮件
await resend.emails.send({
from: process.env.ADMIN_EMAIL!,
to: email,
subject: 'Welcome to Next Forge',
html: `
<h2>Welcome to Next Forge</h2>
<p>Thank you for subscribing to the newsletter. You will receive the latest updates and news.</p>
<p style="margin-top: 20px; font-size: 12px; color: #666;">
If you wish to unsubscribe, please <a href="${unsubscribeLink}">click here</a>
</p>
`,
headers: {
"List-Unsubscribe": `<${unsubscribeLink}>`,
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
}
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('邮箱订阅失败:', error);
return NextResponse.json({ error: '服务器处理请求失败' }, { status: 500 });
}
}
这个 API 可以接受邮箱,并把邮箱添加到 Resend 的 Audience 面板,这样可以很方便地管理订阅者,同时向订阅者发送一封订阅成功的提醒。
因为根据邮件营销最佳实践,每一封发给订阅者的邮件都要提供退订入口,所以邮箱里允许用户打开 unsubscribe
页面进行退订,我们通过 Buffer.from(normalizedEmail).toString('base64')
生成当前用户标识。
用户退订会打开一个携带唯一标识的地址,我们可以写一个服务端组件来接收和处理:
// app/unsubscribe/page.tsx
// 完整代码地址:https://github.com/weijunext/nextjs-15-starter/blob/main/app/unsubscribe/page.tsx
export default async function UnsubscribePage({ searchParams }: { searchParams: { token?: string } }) {
let status: "error" | "success" = "error";
let email = "";
let errorMessage = "处理您的退订请求时出现问题";
const token = searchParams.token;
if (!token) {
errorMessage = "未提供退订令牌";
} else {
// 执行退订操作
const result = await unsubscribe(token);
if (result.success) {
status = "success";
email = result.email || "";
} else {
errorMessage = result.error || "处理您的退订请求时出现问题";
}
}
return (
<div>
<h1>邮件订阅管理</h1>
{status === "success" ? (
<div>
<p>您已成功退订「Next.js 中文文档」的邮件通知。</p>
<p>邮箱: {email}</p>
</div>
) : (
<div>
<p>{errorMessage}</p>
<p>请确保您使用了正确的退订链接。</p>
</div>
)}
</div>
);
}
以上即可完成一个 newsletter 功能,你可以到 nextforge.dev 体验。
文章开头说到,本文是接入基础,所以如果你的产品用户量比较大、业务逻辑复杂、安全要求高,你必须在基础功能以上,自主添加更多安全措施,例如利用 upstash redis 的 limiter 防止恶意重复提交、开启机器人识别、中间件对请求进行安全过滤等等多种措施。
🧑💻独立开发|⛵️出海|Next.js手艺人
🖥️做过开源:http://github.com/weijunext
⌨️写过博客:https://weijunext.com
🛠️今年想做独立产品和课程
📘Nextjs中文文档:http://nextjscn.org
📙全栈开发教程:https://ship.weijunext.com
欢迎在以下平台关注我:
2025-03-18 08:00:00
Web 添加 Google Analytics 是非常容易的,引入一个脚本就行,如果你还不会,可以直接用我的 Next.js 启动模板。
但是,Chrome 插件要添加 Google Analytics 就不太方便了,因为从 manifest v3 开始,不允许使用外部 js 脚本了,也就是无法通过注入 Google Analytics 提供的脚本来实现。根据最新的规范,Chrome 插件想要添加 Google Analytics,需要由产品端主动发起 post 请求,向 Google Analytics 提交数据。
本文就来介绍一下如何在 Plasmo 开发的 Chrome 插件里添加 Google Analytics 4 (GA4)。
在添加 Google Analytics 之前,我都是在 Chrome 插件后台看安装数据,其中有一项是「一段时间内的每周用户数」,我一直以为展示的是日活数据,直到我点开这个小问号:
原来这里还包含了已停用的用户,那么和日活数据会有很大出入。为了能看到相对精准的数据,我决定引入 Google Analytics。
当然,除了想要更精准的数据外,从运营侧考虑还可能有这些原因:
接下来我们进入正题——介绍实现步骤。
登录 Google Analytics,在右下角 Admin - Create - Property 这里创建一个新的媒体
创建完成后,进入当前媒体的页面,来到 Admin - Data collecting and modification - Data streams
点击所创建的数据流,会看到这样的界面:
复制 Measurement Id,并且在 Measurement Protocol API secrets 这里创建一个新的 api_secret
把上面复制的 Measurement Id 和 创建的 Measurement Protocol API secrets 加入环境变量
PLASMO_PUBLIC_GTAG_ID=G-XXXXXXXXXX # Measurement Id
PLASMO_PUBLIC_SECRET_API_KEY=XXXXXXXX # api_secret
核心代码只有一个文件,我们先创建一个文件 lib/googleAnalytics.ts
,一步步加入代码
import { Storage } from "@plasmohq/storage"
if (!process.env.PLASMO_PUBLIC_GTAG_ID) {
throw new Error("PLASMO_PUBLIC_GTAG_ID 环境变量未设置。")
}
if (!process.env.PLASMO_PUBLIC_SECRET_API_KEY) {
throw new Error("PLASMO_PUBLIC_SECRET_API_KEY 环境变量未设置。")
}
const GA_ENDPOINT = "https://www.google-analytics.com/mp/collect"
const G_TAG_ID = process.env.PLASMO_PUBLIC_GTAG_ID
const SECRET_API_KEY = process.env.PLASMO_PUBLIC_SECRET_API_KEY
const SESSION_EXPIRATION_IN_MSEC = 1000 * 60 * 30 // 30分钟
const storage = new Storage()
代码首先确保所需的环境变量存在。Plasmo 的 Storage
模块为客户端ID和会话管理提供持久化存储。
Google Analytics 需要一个稳定的客户端ID来跟踪跨会话的用户:
async function getClientId(): Promise<string> {
let clientId = await storage.get<string>("ga_client_id")
if (!clientId) {
// 生成UUID v4
clientId = crypto.randomUUID()
await storage.set("ga_client_id", clientId)
}
return clientId
}
此函数检索现有客户端ID或使用 crypto.randomUUID()
创建新ID,它生成UUID v4——非常适合匿名用户识别。
会话帮助分组用户交互:
async function getSessionId(): Promise<string> {
let sessionId = await storage.get<string>("ga_session_id")
const sessionTimestamp = await storage.get<number>("ga_session_timestamp") || 0
// 在以下情况创建新会话:
// 1. 没有会话存在
// 2. 会话已超过 SESSION_EXPIRATION_IN_MSEC
const now = Date.now()
if (!sessionId || (now - sessionTimestamp > SESSION_EXPIRATION_IN_MSEC)) {
sessionId = crypto.randomUUID()
await storage.set("ga_session_id", sessionId)
await storage.set("ga_session_timestamp", now)
}
return sessionId
}
这段代码创建或检索会话ID并管理会话超时。
因为 Chrome 插件和 Web 是不一样的,本身没有会话概念,所以我们自定义一个会话和超时时间,如果在超时时间内重复打开插件,就当作是一个会话。
async function isAnalyticsOptedOut(): Promise<boolean> {
return await storage.get<boolean>("analytics_opted_out") || false
}
export async function optOutOfAnalytics(): Promise<void> {
await storage.set("analytics_opted_out", true)
}
export async function optInToAnalytics(): Promise<void> {
await storage.set("analytics_opted_out", false)
}
根据 Google 给的规范,如果有收集用户数据,最好提供一个允许用户关闭的入口,所以我们需要在这里添加以上 3 个方法来支持这个功能。
这一步是向 GA 发送事件的核心函数:
export async function sendEvent(event: CollectEventPayload): Promise<void> {
// 如果用户选择退出则跳过
if (await isAnalyticsOptedOut()) return
try {
const clientId = await getClientId()
const sessionId = await getSessionId()
const url = `${GA_ENDPOINT}?measurement_id=${G_TAG_ID}&api_secret=${SECRET_API_KEY}`
const payload = {
client_id: clientId,
session_id: sessionId, // 如果注释掉,每次打开都会被统计到
events: [event],
// 需要时包含用户属性
user_properties: {
extension_version: {
value: chrome.runtime.getManifest().version
}
}
}
const response = await fetch(url, {
method: "POST",
body: JSON.stringify(payload),
headers: {
"Content-Type": "application/json"
}
})
if (!response.ok) {
console.error("GA4事件跟踪失败:", await response.text())
}
} catch (error) {
console.error("发送分析事件时出错:", error)
}
}
这个函数:
我们把常用的事件也定义在这个页面,例如:
export const Events = {
PAGE_VIEW: (page_title: string) => ({
name: "page_view",
params: {
page_title,
page_location: document.location.href
}
}),
BOOKMARK_ADDED: (source: string) => ({
name: "bookmark_added",
params: {
source
}
}),
SEARCH: (search_term: string, engine: string) => ({
name: "search",
params: {
search_term,
engine
}
}),
// 更多事件...
}
// lib/googleAnalytics.ts
import { Storage } from "@plasmohq/storage"
if (!process.env.PLASMO_PUBLIC_GTAG_ID) {
throw new Error("PLASMO_PUBLIC_GTAG_ID environment variable not set.")
}
if (!process.env.PLASMO_PUBLIC_SECRET_API_KEY) {
throw new Error("PLASMO_PUBLIC_SECRET_API_KEY environment variable not set.")
}
const GA_ENDPOINT = "https://www.google-analytics.com/mp/collect"
const G_TAG_ID = process.env.PLASMO_PUBLIC_GTAG_ID
const SECRET_API_KEY = process.env.PLASMO_PUBLIC_SECRET_API_KEY
const SESSION_EXPIRATION_IN_MSEC = 5 * 60 * 1000 // 5 minutes
// const DEFAULT_ENGAGEMENT_TIME_IN_MSEC = 100
const storage = new Storage()
// https://developers.google.com/analytics/devguides/collection/protocol/ga4/reference/events
type CollectEventPayload = {
name: string
params?: Record<string, any>
}
// Generate or retrieve a unique client ID
async function getClientId(): Promise<string> {
let clientId = await storage.get<string>("ga_client_id")
if (!clientId) {
// Generate a UUID v4
clientId = crypto.randomUUID()
await storage.set("ga_client_id", clientId)
}
return clientId
}
// Generate or retrieve session ID
async function getSessionId(): Promise<string> {
let sessionId = await storage.get<string>("ga_session_id")
const sessionTimestamp = await storage.get<number>("ga_session_timestamp") || 0
// Create a new session if:
// 1. No session exists
// 2. Session is older than SESSION_EXPIRATION_IN_MSEC
const now = Date.now()
if (!sessionId || (now - sessionTimestamp > SESSION_EXPIRATION_IN_MSEC)) {
sessionId = crypto.randomUUID()
await storage.set("ga_session_id", sessionId)
await storage.set("ga_session_timestamp", now)
}
return sessionId
}
// Check if user has opted out of analytics
async function isAnalyticsOptedOut(): Promise<boolean> {
return await storage.get<boolean>("analytics_opted_out") || false
}
// Send event to Google Analytics 4
export async function sendEvent(event: CollectEventPayload): Promise<void> {
// Skip if user opted out
if (await isAnalyticsOptedOut()) return
try {
const clientId = await getClientId()
const sessionId = await getSessionId()
const url = `${GA_ENDPOINT}?measurement_id=${G_TAG_ID}&api_secret=${SECRET_API_KEY}`
const payload = {
client_id: clientId,
// session_id: sessionId,
events: [event],
// Include user properties if needed
user_properties: {
extension_version: {
value: chrome.runtime.getManifest().version
}
}
}
const response = await fetch(url, {
method: "POST",
body: JSON.stringify(payload),
headers: {
"Content-Type": "application/json"
}
})
if (!response.ok) {
console.error("GA4 event tracking failed:", await response.text())
}
} catch (error) {
console.error("Error sending analytics event:", error)
}
}
// Common events
export const Events = {
PAGE_VIEW: (page_title: string, page_location: string) => ({
name: "page_view",
params: {
page_title,
// engagement_time_msec: DEFAULT_ENGAGEMENT_TIME_IN_MSEC,
page_location: document.location.href
}
}),
BOOKMARK_ADDED: (source: string) => ({
name: "bookmark_added",
params: {
source
}
}),
SEARCH: (search_term: string, engine: string) => ({
name: "search",
params: {
search_term,
engine
}
}),
TRENDING_FILTER: (language: string, time_range: string) => ({
name: "trending_filter",
params: {
language,
time_range
}
}),
EXTERNAL_LINK_CLICK: (link_url: string, link_domain: string, link_type: string) => ({
name: "external_link_click",
params: {
link_url,
link_domain,
link_type
}
}),
THEME_CHANGE: (theme: string) => ({
name: "theme_change",
params: {
theme
}
})
}
// Add functions for opt-in/out
export async function optOutOfAnalytics(): Promise<void> {
await storage.set("analytics_opted_out", true)
}
export async function optInToAnalytics(): Promise<void> {
await storage.set("analytics_opted_out", false)
}
以新标签页插件为例,需要在 tabs/index.tsx
文件里调用:
import { Events, sendEvent } from "~lib/googleAnalytics"
function NewTab() {
useEffect(() => {
// 当新标签页打开时跟踪页面浏览
sendEvent(Events.PAGE_VIEW("新标签页"))
}, [])
// 组件实现...
}
如果你的插件是 Popup,用法也是一样。
为了让 GA 工作,还需要在 manifest 添加权限:
"host_permissions": [
"https://www.google-analytics.com/*"
],
"permissions": [
"storage"
]
本文介绍了在 Plasmo 开发的 Chrome 插件里如何添加 Google Analytics,并且实现了这些特性:
本文的实现方式本来要在我的插件 nTab 中使用,但最终考虑到客户端还要发送请求,而且国内用户可能无法发送成功,所以暂时取消了。如果你们的插件主要面向海外用户,可以尝试使用起来。
nTab 是一个为程序员/开发者专门设计的新标签页插件,你可以在这里查找优质的开源项目、GitHub Trending、Hacker News 和其他多个平台的热门信息,还可以自定义常用标签,让工作更高效。未来会陆续增加新功能,欢迎开发者来围观和使用!
🧑💻独立开发|⛵️出海|Next.js手艺人
🖥️做过开源:http://github.com/weijunext
⌨️写过博客:https://weijunext.com
🛠️今年想做独立产品和课程
📘Nextjs中文文档:http://nextjscn.org
📙全栈开发教程:https://ship.weijunext.com
欢迎在以下平台关注我:
2025-02-06 08:00:00
昨天突然发现一个部署在 Zeabur 的网站打不开了,这个网站最近的访问量持续上涨,无法访问意味着会失去潜在的新用户,而我在 Zeabur 每个月付费 10+ 刀。支付着高额账单却没有稳定的服务,于是我决定把部署在 Zeabur 的服务迁移走。
正好我最近计划购买服务器自部署一些开源服务,趁这个机会把 Dokploy 给部署起来,既可以替换掉 Zeabur,也可以把 Vercel 上面一部分占用资源大的项目迁移过来,还能作为以后自部署开源服务的稳定性试验,一举多得。
需要说明的是,我在 Zeabur 遇到的问题可能只是个例。事实上,Zeabur 是一个优秀的部署平台,我有其他项目在上面运行了很长时间都非常稳定。本文重点是分享自部署 Dokploy 的经验,而不是对任何平台的评价。
Dokploy 是一个专注于自托管的 PaaS (Platform as a Service) 解决方案,是 Vercel/Netlify/Zeabur 的开源替代品,区别是 Dokploy 专注于 Docker 容器部署。
如果你熟悉 Docker,会很容易上手本文介绍的工作流,如果不熟悉,可能就会和我一样花半天时间才搞定一切。
要自部署 Dokploy,首先得购买服务器。对比几个云厂商的价格后,我选择了 hostinger 的 vps。
进入 hostinger 网站后,先把语言切换到中文再购买,因为我对比了几个地区的价格,发现人民币付款是最便宜的。
选购2核8G的vps,两年只要1037元,换算下来,每个月不到6刀,还要啥自行车。
付款后会进入vps设置流程,根据页面提示操作就好了,全部完成后控制台会显示vps是启动状态。
接着添加防火墙规则,hostinger 默认没有防火墙规则,意味着所有端口都是开放的,这样风险比较高。我们需要创建防火墙规则,并开放 22、80、443、3000 端口访问。
打开命令行,使用 ssh 方式登录vps,执行 Dokploy 安装命令:
curl -sSL https://dokploy.com/install.sh | sh
等待一会儿,安装成功后会有提示:
现在打开截图里的地址就可以访问 Dokploy 的管理后台。
注册登录后,进入如图的管理后台,先给管理后台设置自定义域名
再到域名解析平台(我的网站在 CloudFlare 解析,所以截图是 CloudFlare 的界面)添加这个自定义域名的解析记录,选择 A 类型解析,IP 地址填写服务器地址。
解析成功后,就可以使用自定义域名访问 Dokploy 管理后台了。
接着绑定你的 git 账号,这一步还是跟着页面提示操作,步骤和其他平台差不多,绑定完成后如图👇
在 Vercel 上面,我们可以直接选择项目进行构建,不过在 Dokploy 需要先创建 Project,再创建 Service,然后才能部署项目
同一个 Project 下面的 Service 可以设置公共环境变量
创建 Service,并进入 Service 管理界面
在 Provider 依次选择账号、仓库、分支,然后点击 Save,再开始 Deploy
每个 Service 都有通用设置(General)、环境变量(Enviroment)、日志(Logs)、构建(Deployments)等等设置项,这些功能也跟 Vercel 等平台类似。
需要提醒的是,域名重定向的功能不在 Domain 里面,而是在 Advanced - Redirects。
通常我们选择「带 www
前缀的域名指向不带 www
的域名地址」,也就是下拉框的第二个选项
等 Service 构建完成了,我们到 Domains 里面添加域名,可以手动输入自定义域名,也可以点击右边的骰子生成域名
生成域名后,点击能打开页面就验证部署成功了。
如果是在 CloudFlare 解析域名,需要给 SSL 选择“完全”的策略
起因是,不少 Dokploy 尝鲜用户发现在服务器负载高的时候,会出现部署失败的情况,有开发者提出解决方案:搭配 GitHub Actions,部署前先通过 GitHub Actions 自动构建 Docker 镜像,Dokploy 只需要拉取构建好的镜像,不需要执行构建程序,这样就不会出现构建失败的问题。
要使用 GitHub Actions,需要先到 GitHub 设置里创建 Personal access token,点此直达,创建一个新的 Token
创建完成后,会看到 token,要保存下来,等下要用。
如果忘了保存,可以重新回到这里,点击蓝色字进去重新生成(Regenerate token)
回到 Dokploy 管理后台,进入 Registry 模块,添加 Registry
Registry Name 随便填,Username 填你的 GitHub ID,Password 填上面生成的 token,Reigstry URL 填 https://ghcr.io
再打开 Service 的管理页面,进入 Advanced,我们要修改 Cluster Settings
registry 选择刚才创建的那一个,然后 Save
接着点开 General,Provider 选择 Docker,Docker Image 输入 ghcr.io/[GitHub ID]/[Repo Name]:[Branch]
,然后 Save
修改成功后,会看到 Service 状态是置灰的。我们点开 Deployments,可以看到一个 Webhook URL,这个 URL 下一步要用。
在根目录创建文件 .github/workflow/docker-image.yml
,这是一个 GitHub Actions 工作流配置文件,用来自动化构建和发布 Docker 镜像:
name: Create and publish a Docker image
on:
push:
branches: ['main']
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
attestations: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push Docker image
id: push
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Trigger dokploy redeploy
run: |
curl -X GET https://xxxxxxxxxxxxxxxxxxxxx
注意看最后一行,https地址需要换成上一步看到的 Webhook URL。
现在还需要在根目录创建 Dockerfile
文件来定义如何构建这个镜像:
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
RUN corepack enable && corepack prepare [email protected] --activate && pnpm i --frozen-lockfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED 1
RUN corepack enable && corepack prepare [email protected] --activate && pnpm build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
CMD ["node", "server.js"]
我使用的是 pnpm
,所以这里配置了 pnpm
命令,并且要保证 pnpm
版本和 pnpm-lock.yaml
匹配,我直接设置为和本地一样的版本号,也就是 [email protected]
,你使用的时候最好换成自己的版本号,否则容易构建失败。
在 Docerkfile 配置里,我们使用了 standalone
输出模式,这个模式可以减少最终镜像的大小,并优化部署性能。对应的,我们还需要在 next.config.mjs
里面添加 output: standalone
这个配置:
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
// ... 其他配置
}
出于规范,还可以在根目录添加 .dockerignore
文件:
.git
.github
node_modules
.next
现在提交代码,打开 GitHub 仓库的 Actions 模块,会看到工作流正在执行,等待几分钟执行完成后,到 Dokploy 管理后台查看构建记录,会看到最新镜像已经拉取到了并且更新完成了
到这里就算完成整个流程了,以后代码更新就会自动触发构建流程,自动更新。
按照这样的步骤,可以快速把 Vercel 等平台的项目迁移过来了,我已经把「Next.js 中文文档」迁移到自部署的 Dokploy,欢迎大家一起体验服务的稳定性🤣。
最后提醒一下,建议在 Web Server 模块打开每日清除 Docker 镜像的功能,这样系统会自动清除废弃的镜像,节省服务器空间。
这是很重要的一步,以上使用 GitHub Actions 部署的方式需要把 .env
文件上传到 GitHub 才行,但是大多数情况下我们是不会把 .env
文件上传网络。
这时候就需要使用 GitHub Actions 的环境变量设置功能了,打开 GitHub - settings - secrets and variables - Actions - Secrets/Variables;如果是需要加密的环境变量,要添加到 Secrets,GitHub 会进行加密处理,如果无需加密,添加到 Variables 即可。
在 GitHub 上添加环境变量后,还需要修改 .github/workflows/docker-image.yml
和 Dockerfile
。
在 .github/workflows/docker-image.yml
的 Build and push Docker image
步骤里添加 build-args
:
// docker-image.yml
- name: Build and push Docker image
id: push
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# 如果是添加到 Secrets,这里使用 secrets.xxx,添加到 Variables 的使用 vars.xxx
build-args: |
NEXT_PUBLIC_SITE_URL=${{ vars.NEXT_PUBLIC_SITE_URL }}
NEXT_PUBLIC_SITE_NAME=${{ vars.NEXT_PUBLIC_SITE_NAME }}
## 其他更多环境变量
对应的,在 Dockerfile
的构建阶段接收这些环境变量:
## 其他内容不变
# Rebuild the source code only when needed
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# 声明构建参数
ARG NEXT_PUBLIC_SITE_URL
ARG NEXT_PUBLIC_SITE_NAME
# 设置为环境变量,使应用能够访问
ENV NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL}
ENV NEXT_PUBLIC_SITE_NAME=${NEXT_PUBLIC_SITE_NAME}
## 其他内容不变
这样就能让 GitHub Acitons 读取到环境变量了。
以上内容参考了 Dokploy 官方文档:
还有 javahu 老哥的工作流:
🧑💻独立开发|⛵️出海|Next.js手艺人
🖥️做过开源:http://github.com/weijunext
⌨️写过博客:https://weijunext.com
🛠️今年想做独立产品和课程
📘Nextjs中文文档:http://nextjscn.org
📙全栈开发教程:https://ship.weijunext.com
欢迎在以下平台关注我:
2025-01-17 08:00:00
今天正式发布了我的第一个新标签页插件:nTab
这是一个为开发者、独立开发者量身打造的新标签页插件,主要功能分3个模块:
有了这个插件,每次打开浏览器都能查找新 idea,一下子缩短了信息获取路径。
插件左侧的优质开源项目来源于我的开源项目导航站:Next Idea
这篇博客的主要目的不是介绍插件,是要讲一个插件里出现的一个 bug。
在插件的右侧,是一大批工具的展示,每个工具都需要显示 logo,问题恰恰出现在 logo 的展示上。
logo 最初的展示方案是,手动从第三方网站里找到高清 logo 地址,如果没有高清 logo,则使用 https://favicon.im 的服务获取logo。这个方案在我测试的时候没发现问题,上线后有用户反馈页面上出现 bug:
我第一反应就知道和国内网络连接 favicon.im 的稳定性有关。
解决这个 bug 的思路也简单:
启动 Claude,发送 “我的产品里要展示不同网站的高清 logo,并且要做好回退方案,给我写一个 React 组件”。
Claude 就开发分析如何找到高清 logo:
https://${domain}/apple-touch-icon.png
并且提供了一份组件代码:
import React, { useState } from 'react';
const WebsiteLogo = ({ domain, size = 32, className = "" }) => {
const [imgSrc, setImgSrc] = useState(`https://${domain}/apple-touch-icon.png`);
const [fallbackIndex, setFallbackIndex] = useState(0);
const fallbackSources = [
`https://${domain}/apple-touch-icon.png`,
`https://${domain}/apple-touch-icon-precomposed.png`,
`https://www.google.com/s2/favicons?domain=${domain}&sz=64`,
`https://icons.duckduckgo.com/ip3/${domain}.ico`,
`https://${domain}/favicon.ico`,
];
const handleError = () => {
const nextIndex = fallbackIndex + 1;
if (nextIndex < fallbackSources.length) {
setFallbackIndex(nextIndex);
setImgSrc(fallbackSources[nextIndex]);
}
};
return (
<img
src={imgSrc}
alt={`${domain} logo`}
width={size}
height={size}
onError={handleError}
className={`inline-block ${className}`}
style={{ objectFit: 'contain' }}
/>
);
};
export default WebsiteLogo;
这个组件已经实现了自动处理错误和降级方案,并且可以自定义大小和样式,看起来已经可行了。
但是咱们可是程序员,哪能被 AI 糊弄,继续鞭打,要求在加载状态添加占位符、logo 展示要有过渡效果、提供兜底的回退方案,并且每个回退方案只给1秒加载时间,超出即当作失败,去找下一个回退方案。
这样一份非常健壮的第三方网站 logo 展示的组件就实现好了,代码我放在开源项目 nextjs-15-starter 了,仓库的演示网站也能体验效果。
核心处理方法在这里:
useEffect(() => {
let timeoutId
if (isLoading) {
timeoutId = setTimeout(() => {
handleError()
}, timeout)
}
return () => {
if (timeoutId) {
clearTimeout(timeoutId)
}
}
}, [imgSrc, isLoading])
const handleError = () => {
const nextIndex = fallbackIndex + 1
if (nextIndex < fallbackSources.length) {
setFallbackIndex(nextIndex)
setImgSrc(fallbackSources[nextIndex])
setIsLoading(true)
} else {
setHasError(true)
setIsLoading(false)
}
}
现在组件就完成了如下任务:
轻松解决不同网站的favicon格式不一、图标无法加载、加载超时等等痛点。
最后,欢迎开发者们使用我的新标签页插件
插件官网👉 https://ntab.dev/
安装地址👉 https://chromewebstore.google.com/detail/next-idea-newtab/gneedaehihepidbpdenhmlmgpdaiaepo
🧑💻独立开发|⛵️出海|Next.js手艺人
🖥️做过开源:http://github.com/weijunext
⌨️写过博客:https://weijunext.com
🛠️今年想做独立产品和课程
📘Nextjs中文文档:http://nextjscn.org
📙全栈开发教程:https://ship.weijunext.com
欢迎在以下平台关注我:
2024-11-08 08:00:00
半年前,我开源了一个 Next.js 博客模板。当时为了实现站内搜索,我选择了 FlexSearch 方案。FlexSearch 是一个高性能的全文搜索库,在英文环境下表现优异。然而在实际测试中,我发现中文搜索存在严重问题。要解决这个问题,就需要引入 nodejieba
这样的中文分词库。但 nodejieba
依赖 C++ 编译环境,而我的网站都是部署在 serverless 环境,无法直接支持,所以那时候我只在模板里放了一个简化版的搜索,使用体验并不好。
因为搜索功能使用频率太低了,所以我后来就没怎么研究了。直到和阿伟一起开发 Next.js 中文文档 的时候,阿伟给文档站集成了 Algolia DocSearch,我才重新准备修改博客模板的搜索功能。让我惊讶的是,集成 DocSearch 太方便了,我花了5分钟就上线新的搜索功能。
Algolia 提供了付费的企业版和免费的 DocSearch 两种方案。DocSearch 专门针对技术文档、博客等内容网站,只要你的网站是公开可访问的,就能免费集成。
DocSearch 凭借专业的搜索能力和丝滑的用户体验,已经成为技术文档的主流搜索方案。连 React 和 Vue 这样的官方网站都在使用。
这篇教程将手把手带你在 Next.js 项目中集成 DocSearch,只需跟随以下步骤,你也能很快为网站添加专业级的站内搜索。
填写表单申请:https://docsearch.algolia.com/apply/
等待 Algolia 团队的邮件,等待了一天,我就收到了这样的一封邮件:
收到邮件就表示 Algolia 已经索引好了我们的网站,现在就可以集成了。注意圈起来的参数,后面开发的搜索框组件需要使用这三个核心参数。
安装依赖
# 使用 npm
npm install @docsearch/css @docsearch/react
# 使用 yarn
yarn add @docsearch/css @docsearch/react
# 使用 pnpm
pnpm add @docsearch/css @docsearch/react
创建配置文件 config/docSearchConfig.ts
:
interface DocSearchConfig {
docSearch: {
appId: string;
indexName: string;
apiKey: string;
}
}
export const docSearchConfig: DocSearchSiteConfig = {
docSearch: {
appId: "填写邮件收到的参数",
indexName: "填写邮件收到的参数",
apiKey: "填写邮件收到的参数",
},
}
创建 DocSearch 搜索框组件 components/DocSearch/index.ts
,实现一个功能完备的搜索组件:
"use client";
import { docSearchConfig } from "@/config/docSearch";
import "@docsearch/css";
import { DocSearchModal, useDocSearchKeyboardEvents } from "@docsearch/react";
import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { IoIosSearch } from "react-icons/io";
import "./docSearch.css";
export default function CustomDocSearch() {
const { appId, indexName, apiKey } = docSearchConfig.docSearch;
const [isOpen, setIsOpen] = useState(false);
const [isMac, setIsMac] = useState(false);
const searchButtonRef = useRef<HTMLButtonElement>(null);
const onOpen = useCallback(() => {
setIsOpen(true);
}, [setIsOpen]);
const onClose = useCallback(() => {
setIsOpen(false);
}, [setIsOpen]);
useDocSearchKeyboardEvents({
isOpen,
onOpen,
onClose,
searchButtonRef,
});
// 添加检测操作系统的效果
useEffect(() => {
setIsMac(navigator.platform.toUpperCase().indexOf("MAC") >= 0);
}, []);
return (
<>
<button className="docSearch-btn" data-variant="large" onClick={onOpen}>
搜索文档<kbd>{isMac ? "⌘K" : "Ctrl+K"}</kbd>
</button>
<button className="docSearch-btn" data-variant="medium" onClick={onOpen}>
搜索<kbd>{isMac ? "⌘K" : "Ctrl+K"}</kbd>
</button>
<button
className="docSearch-btn mr-2 hover:bg-accent border border-gray-300"
data-variant="small"
onClick={onOpen}
>
<IoIosSearch />
</button>
{isOpen &&
createPortal(
<DocSearchModal
initialScrollY={window.scrollY}
appId={appId}
apiKey={apiKey}
indexName={indexName}
onClose={onClose}
placeholder="搜索文档"
hitComponent={({ hit, children }) => (
<Link href={hit.url}>{children}</Link>
)}
/>,
document.body
)}
</>
);
}
docSearch.css
是自定义的搜索样式,这部分不重要,不贴代码了,你可以到文末的开源地址查看。
再把搜索框组件 CustomDocSearch
引入 Header 就可以使用了。
DocSearch 的搜索和集成都非常方便,现在我的博客模板、博客、信息差周刊都已经集成好了,这三个项目都是开源的,你可以直接复制我的组件代码。
也可以到「Next.js中文文档」体验,文档站一共有 300 多个文档,搜索效率依然非常高,所以你完全可以放心使用。
我是一名全栈工程师,Next.js 开源手艺人,AI降临派。
今年致力于 Next.js 和 Node.js 领域的开源项目开发和知识分享。
欢迎在以下平台关注我: