其实这个问题我2025-09月份就遇到了,但直到现在2026-01才想到要解决。
原因1:tailwind.config.js 的 content 路径没包含 shadcn 组件目录
安装了二者之后,tailwind.config.js里面不会自动包含shadcn的组件目录,所以需要自己添加:
xxxxxxxxxx131// tailwind.config.js / tailwind.config.ts23module.exports = {4 content: [5 "./app/**/*.{js,ts,jsx,tsx,mdx}",6 "./pages/**/*.{js,ts,jsx,tsx,mdx}",7 "./components/**/*.{js,ts,jsx,tsx,mdx}",8 9 // 必须包含你放 shadcn 组件的路径!10 "./src/components/ui/**/*.{js,ts,jsx,tsx}",11 ],12 // ...其他配置13}原因2:确认全局样式文件被正确引入(通常在根布局)
xxxxxxxxxx111// app/layout.tsx 或 app/layout.js(Next.js App Router)23import './globals.css' // ← 这一行一定要有45export default function RootLayout({ children }) {6 return (7 <html lang="zh-CN">8 <body>{children}</body>9 </html>10 )11}早就该解决了。
| 命令 | 常用别名 | 作用说明 | 相当于 npm 的写法 |
|---|---|---|---|
| pnpm install | pnpm i | 安装 package.json 里全部依赖(最常用命令) | npm install |
| pnpm add | — | 添加依赖到 dependencies 并安装 | npm install |
| pnpm add -D | pnpm add --save-dev | 添加到 devDependencies(开发依赖,如 eslint、vite 等) | npm install -D |
| pnpm add -g | — | 全局安装(如 pnpm 本身、typescript、create-vite 等) | npm install -g |
| pnpm remove | pnpm rm、pnpm un | 删除依赖(会同时从 package.json 移除) | npm uninstall |
| pnpm update | pnpm up、pnpm upgrade | 更新所有依赖到 package.json 范围内最新版 | npm update |
| pnpm update | pnpm up | 只更新某个包 | npm update |
| pnpm list | pnpm ls | 以树状结构列出所有依赖(可加 --depth 0 只看直接依赖) | npm ls |
| pnpm dlx | — | 临时执行某个包的命令(不用全局安装,如 pnpm dlx cowsay hello) | npx |
| pnpm init | — | 初始化项目,生成 package.json | npm init |
| pnpm --version | pnpm -v | 查看 pnpm 版本 | npm -v |
参考:https://www.prisma.io/docs/getting-started/prisma-orm/quickstart/postgresql
1、安装依赖
xxxxxxxxxx71npm install prisma @types/node @types/pg --save-dev 2或者3pnpm add -D prisma @types/node @types/pg45npm install @prisma/client @prisma/adapter-pg pg dotenv6或者7pnpm add @prisma/client @prisma/adapter-pg pg dotenv2、初始化prisma orm
xxxxxxxxxx31npx prisma init --datasource-provider postgresql --output ../generated/prisma2或者3pnpm dlx prisma init --datasource-provider postgresql --output ../generated/prisma3、编辑.env文件,添加数据库地址
我使用的是neon,它提供5G的存储空间,够用了。使用的是google账号登录的。

每一个新项目最好创建一个新数据库,因为prisma操作会同步数据库,如果使用现有的数据库,会造成冲突。那么就点击create database,会创建一个新数据库。

将这个地址粘贴到.env文件里面即可。

4、在prisma/schema.prisma文件里面,定义data model
5、创建和应用迁移
只要model变更了,就执行这两个命令:
xxxxxxxxxx21npx prisma migrate dev --name init2npx prisma generate6、初始化prisma client
创建lib/prisma.ts
prisma v6版本:
xxxxxxxxxx101import "dotenv/config";2import { PrismaPg } from '@prisma/adapter-pg'3import { PrismaClient } from '../generated/prisma/client'45const connectionString = `${process.env.DATABASE_URL}`67const adapter = new PrismaPg({ connectionString })8const prisma = new PrismaClient({ adapter })910export { prisma }prisma v7版本:
7、接下来就可以在lib文件夹里面写相关的数据库操作代码了,先引入定义好的prismaClient,然后正常写代码。
8、添加模拟数据
注意:
postgresql里面的字段名要采用下划线的形式,不要采用小驼峰的形式,下划线是SQL规范。在prisma里面定义的时候也要注意。
但是这样做之后,在组件代码里面使用字段名有不方便了,JS里面使用下划线比较少见,更多的是小驼峰的形式,这该怎么办呢?
prisma有两个方法来帮助我们,
@@map()方法和@map()方法,在定义表的时候,在表的最后面加上表的复数形式@@map("comments"),这样prisma在postgresql创建表的时候,就会自动将数据表的名称变为comments,而不是大驼峰的形式。
https://www.prisma.io/docs/orm/prisma-schema/data-model/models#defining-models
✅
@@map——模型映射(Model/Table Mapping)作用:指定 Prisma model 对应数据库中的表名。
如果 Prisma 模型名是大驼峰(默认是),但你希望数据库用复数表名或蛇形命名,则使用
@@map.简单来讲,
@@map()里面的参数就是定义model名称的复数形式,你指定是什么就是什么,如果不知道英文单词的复数,就搜索一下即可。
✅
@map——字段映射(Field Mapping)作用:指定 Prisma 字段对应数据库中的实际列名。
默认情况下 Prisma 会直接使用字段名作为数据库列名。如果你使用了小驼峰、想让数据库是下划线,就要用
@map。比如说createdAt,用的时候是这样用,但是在定义的时候,postgresql里面需要定义为下划线的形式,就需要这样指定:createdAt DateTime @default(now()) @map("created_at")。下面是一个model样板。
xxxxxxxxxx151model User {2id String @id @default(cuid())3email String @unique4name String?5image String?6role String @default("USER") // USER / ADMIN7createdAt DateTime @default(now()) @map("created_at")8updatedAt DateTime @updatedAt @map("updated_at")910articles Article[]11comments Comment[]12photos Photo[]1314@@map("users")15}
使用https://mockaroo.com/来创建模拟数据。

需要注意的是datetime,要选择SQL datetime这种类型,否则格式不对。

这里就是SQL的插入语句,复制

在编辑器中修改表名,粘贴到neno SQL Editor里面执行即可。这种方法我试过了,太复杂了,所以还是使用本地编辑器导入csv文件来做吧。
neon的链接地址:
xxxxxxxxxx11DATABASE_URL='postgresql://neondb_owner:npg_PdcV96hSGZFR@ep-misty-meadow-a8g8kxvi-pooler.eastus2.azure.neon.tech/neondb?sslmode=require&channel_binding=require'➤ Host(服务器地址)
xxxxxxxxxx11ep-msty-meadow-a8g8kxvi-pooler.eastus2.azure.neon.tech
➤ Port
Neon 默认 PostgreSQL 端口是:
xxxxxxxxxx115432
➤ Database
xxxxxxxxxx11neondb
➤ Username
xxxxxxxxxx11neondb_owner
➤ Password
xxxxxxxxxx11PdcV96hSGZFR
粘贴进去即可连接。然后导入数据。


注意:
Prisma 遵循 camelCase (驼峰命名法) 定义模型字段,但大多数传统的 PostgreSQL 数据库默认使用 snake_case (下划线命名法) 作为物理列名。所以我在添加数据的时候,如果是这样的语句,会报错
createat column does not exist:xxxxxxxxxx11insert into public."Product" (id, name, description, price, image, stock, sales, category, createAt, updateAt) values (1, 'Portable Solar Generator', 'Eco-friendly generator for outdoor adventures.', 399.99, 'http://dummyimage.com/185x100.png/5fa2dd/ffffff', '67989', '3837', 'Outdoor', '2025-03-12 15:38:53', '2025-07-14 17:13:57');所以需要改为这样
craete_at,update_at,也就是说凡是有小驼峰命名的地方,都要改为下划线命名:xxxxxxxxxx11insert into public."Product" (id, name, description, price, image, stock, sales, category, create_at, update_at) values (1, 'Portable Solar Generator', 'Eco-friendly generator for outdoor adventures.', 399.99, 'http://dummyimage.com/185x100.png/5fa2dd/ffffff', '67989', '3837', 'Outdoor', '2025-03-12 15:38:53', '2025-07-14 17:13:57');
✅ 原因 1:Neon Serverless 会自动断开空闲连接
Neon 是 serverless Postgres,为了节省资源,它会自动关闭长时间空闲的连接。
默认情况(Neon 免费 tier):
| 行为 | Neon 的做法 |
|---|---|
| 连接 5 分钟无活动 | 会自动关闭连接 |
| 空闲到达阈值 | 会 suspend compute(自动休眠) |
| 再次连接 | 需要唤醒 → 所以你看到“重新连接”提示 |
这就是为什么 DBeaver 停在那里不动一会儿之后,连接突然失效。
✅ 原因 2:你使用的是 Neon 的 \pooler\ 端点
你现在的连接串是这样的:
xxxxxxxxxx11...-pooler.eastus2.azure.neon.tech
Neon 的 Pooler 也有自己的超时机制:
| 机制 | 描述 |
|---|---|
pooler_idle_timeout | 连接一段时间不活跃会被关闭 |
pooler_max_client_conn | 超过总连接数会丢弃/挤掉连接 |
➡ 所以 DBeaver 会被踢掉是完全正常的。
✅ 原因 3:DBeaver 默认不会自动保持心跳
DBeaver 对 PostgreSQL 不会默认发心跳包(KeepAlive)。
因此:
= 连接被断开,必须重新连接。

nextjs的Image组件,不能直接访问外部图片链接,需要在next.config.ts里面进行配置:

一句话,采用kebab-case(小写 + 连字符)来命名。
Next.js App Router(即 app/ 目录下的路由系统)对文件夹命名风格有比较明确的社区共识和推荐做法,虽然官方文档没有强制规定大小写,但强烈建议统一风格来保持 URL 的干净、可读性和一致性。
1、推荐的文件夹命名风格(2025-2026 年主流做法)
文件夹(route segment):全部使用 kebab-case(小写 + 连字符)
about、user-profile、settings、blog-post/about、/user-profile、/settings、/blog-post为什么不是小驼峰(camelCase)?
aboutMe/page.tsx,访问路径会变成 /aboutMe(大写 M 保留)/aboutMe 和 /aboutme 视为不同路径,造成潜在重复内容问题)你的命名 aboutMe/page.tsx 可以吗?
技术上可以运行,Next.js 不会报错,访问 /aboutMe 也正常。
但不推荐,原因如下:
/aboutMe(混合大小写),不够“干净”eslint-plugin-check-file)或团队规范会强制 kebab-case,报错2、推荐改成这样(最佳实践)
xxxxxxxxxx31app/2 about-me/ ← kebab-case,全小写 + 连字符3 page.tsx或者更简洁:
xxxxxxxxxx31app/2 about/ ← 如果意思够表达,就用单个词3 page.tsx3、完整推荐命名对照表(App Router)
| 类型 | 推荐风格 | 示例文件夹/文件 | 对应 URL | 备注 |
|---|---|---|---|---|
| 普通路由文件夹 | kebab-case | user-profile, blog-posts | /user-profile | 最常见,URL 最友好 |
| 动态路由文件夹 | kebab-case + [] | [userId], [slug] | /{动态值} | 官方推荐小写 |
| Route Group(分组) | (任意,但建议 kebab) | (marketing), (auth) | 不影响 URL | 括号内可随意,但保持一致 |
| Private Folder | _开头 | _components, _lib | 不生成路由 | 官方支持,用于 colocated 文件 |
| 页面文件 | 固定 | page.tsx、layout.tsx 等 | — | 必须是这些文件名,不能改 |
| 组件文件(非路由) | kebab-case 或 PascalCase | UserProfile.tsx 或 user-profile.tsx | — | 看团队偏好,组件名本身用 PascalCase |
可以使用kebab-case(小写 + 连字符)来命名,也可以使用PascalCase(大驼峰)来命名,看要求。
xxxxxxxxxx21components/user-profile.tsx 或者2components/UserProfile.tsx NuQS 的核心就是 URL query state 管理器,分 Server 端和 Client 端使用场景:
Server Components / Route Handler
nuqs/serverClient Components
useQueryState + nuqs(注意不是 nuqs/server)重点:search params创建 + pagination + 具体的components解析search Params,必须保持全部是server side或者全部是client side。如果不能保证全部一致,则翻页不生效。这是我实际写的过程中得出的经验。
刚开始并不会写,于是叫AI帮我实现,但是AI没有将三个内容保持一致,所以实现不了,于是问AI有没有投诉nuqs不能实现翻页,AI grok说没有,倒是有一个中文用户投诉(我猜想这个人就是我),既然没有一个人认为nuqs使用起来有问题,那么肯定是我自己的问题。于是仔细看文档,看官方示例代码,同时下载一个github上的示例代码(nextjs + nuqs),发现它能够正常翻页,那么肯定是我自己的问题了。后面就发现了原来 search params创建 + pagination + 具体的components解析search Params,必须保持server或者client一致。
参考:https://nuqs.dev/docs/server-side
使用loader函数,在server端来创建和解析search params。
1、定义搜索参数,并创建服务器端加载器和搜索序列化
xxxxxxxxxx111import { createLoader, createSerializer, parseAsInteger } from "nuqs/server";23const searchParams = {4 page: parseAsInteger.withDefault(1),5};67// 使用 createLoader 函数,创建服务器端加载器8export const loadPagination = createLoader(searchParams);910// 创建序列化的搜索参数,方便跳转时使用11export const getPaginatedLink = createSerializer(searchParams);2、在server component里面解析并使用搜索参数
xxxxxxxxxx291import { loadPagination } from "@/hooks/use-query";2import { getCategoryByPage } from "@/lib/data";3import type { SearchParams } from "nuqs/server";4import { CategoryList } from "./category-list";5import { dalVerifySuccess } from "@/dal/helpers";67export default async function CategoryTableContent({8 params,9}: {10 params: Promise<SearchParams>;11}) {12 // 使用loadPagination来解析参数13 const { page } = await loadPagination(params);14 const currentPage = Number(page) || 1;15 16 const { categories, totalPages } = dalVerifySuccess(17 await getCategoryByPage(currentPage),18 );1920 return (21 <>22 <CategoryList23 categories={categories}24 totalPages={totalPages}25 page={page}26 />27 </>28 );29}关键点:
loadPagination 返回一个对象,里面是解析后的值(带默认值)。await loadPagination(searchParams) 的 Promise 直接传给子组件 + <Suspense>,实现 Partial Prerendering (PPR),让页面静态壳更快渲染。3、pagination组件接收到当前的searchParams,然后根据searchParams做相应的处理
这里只有一个page参数,实际上可以有很多参数。我将page参数传递给pagination组件之后,就可以高亮显示当前页,并且将page应用到prevPage和nextPage的href上面,进行翻页。
xxxxxxxxxx361// CategoryList.tsx23"use client";45import CommonPagination from "@/components/common-pagination";6import CommonTable, { type Columns } from "@/components/common-table";78export const CategoryList = ({9 categories,10 totalPages,11 page,12}: {13 categories: BasicCategory[];14 totalPages: number;15 page: number;16}) => {17 const columns: Columns<BasicCategory>[] = [18 {19 header: "Name",20 render: (item: BasicCategory) => item.name,21 },22 23 ];2425 return (26 <>27 28 <CommonTable columns={columns} data={optimisticCategories} />29 <CommonPagination30 page={page}31 totalPages={totalPages}32 location="/admin-category"33 />34 </>35 );36};xxxxxxxxxx1021import { getPaginatedLink } from "@/hooks/use-query";2import {3 Pagination,4 PaginationContent,5 PaginationItem,6 PaginationLink,7 PaginationNext,8 PaginationEllipsis,9 PaginationPrevious,10} from "./ui/pagination";1112export default function CommonPagination({13 totalPages,14 page,15 location,16}: {17 totalPages: number;18 page: number;19 location: string;20}) {21 if (totalPages <= 1) return null;2223 function pageURL(page: number) {24 const safePage = Math.max(1, Math.min(page, totalPages));25 return getPaginatedLink(location, {26 page: safePage,27 });28 }2930 const getVisiblePages = () => {31 const maxVisible = 5;32 if (totalPages <= maxVisible + 2) {33 return Array.from({ length: totalPages }, (_, i) => i + 1);34 }3536 const pages: (number | string)[] = [];37 const start = Math.max(2, page - 1);38 const end = Math.min(totalPages - 1, page + 1);3940 pages.push(1);4142 if (start > 2) pages.push("ellipsis-start");4344 for (let i = start; i <= end; i++) {45 pages.push(i);46 }4748 if (end < totalPages - 1) pages.push("ellipsis-end");4950 pages.push(totalPages);51 return pages;52 };5354 const visiblePages = getVisiblePages();5556 return (57 <Pagination className="mt-6">58 <PaginationContent className="w-full flex items-center justify-end">59 <PaginationItem>60 <PaginationPrevious61 href={pageURL(page - 1)}62 className={63 page <= 1 ? "pointer-events-none opacity-50" : "cursor-pointer"64 }65 />66 </PaginationItem>6768 {visiblePages.map((p, i) => {69 if (p === "ellipsis-start" || p === "ellipsis-end") {70 return (71 <PaginationItem key={`ellipsis-${i}`}>72 <PaginationEllipsis />73 </PaginationItem>74 );75 }7677 const pageNum = p as number;78 return (79 <PaginationItem key={i}>80 <PaginationLink81 href={pageURL(pageNum)}82 isActive={page === pageNum}>83 {pageNum}84 </PaginationLink>85 </PaginationItem>86 );87 })}8889 <PaginationItem>90 <PaginationNext91 href={pageURL(page + 1)}92 className={93 page >= totalPages94 ? "pointer-events-none opacity-50"95 : "cursor-pointer"96 }97 />98 </PaginationItem>99 </PaginationContent>100 </Pagination>101 );102}重点需要注意的是:shadcn的PaginationPrevious、PaginationLink、PaginationNext组件上,需要指定href来进行跳转,这个href应该是完整的路由地址。我之前使用的是下面这样的方法,是不行的。
下面就是AI告诉我的方法,因为在server components里面不能使用usePaginationQuery来解析search params,所以AI另外使用createLoader来创建同样参数的解析器loadPagination, 可是usePaginationQuery和loadPagination之间根本就没有关系,二者之间的变化都不能传递到彼此,所以是完全没有作用的。就是GROK告诉我的错误答案。
xxxxxxxxxx1191// 在server端是不行的,必须保持 search params创建 + pagination + 具体的components解析search Params 都是server端2// 但是这种方法在全部是client端时,是可以的34// 定义搜索参数5import { parseAsInteger, useQueryState } from "nuqs";6export function usePaginationQuery() {7 return useQueryState(8 "page",9 parseAsInteger.withDefault(1).withOptions({10 shallow: false,11 }),12 );13}141516// pagination组件17"use client";18import { usePaginationQuery } from "@/hooks/use-pagination-query";19import {20 Pagination,21 PaginationContent,22 PaginationItem,23 PaginationLink,24 PaginationNext,25 PaginationEllipsis,26 PaginationPrevious,27} from "./ui/pagination";28export default function CommonPagination({29 totalPages,30}: {31 totalPages: number;32}) {33 const [page, setPage] = usePaginationQuery();34 console.log({ totalPages });35 if (totalPages <= 1) return null;36 const getVisiblePages = () => {37 const maxVisible = 5;38 if (totalPages <= maxVisible + 2) {39 return Array.from({ length: totalPages }, (_, i) => i + 1);40 }41 const pages: (number | string)[] = [];42 const start = Math.max(2, page - 1);43 const end = Math.min(totalPages - 1, page + 1);4445 pages.push(1);4647 if (start > 2) pages.push("ellipsis-start");4849 for (let i = start; i <= end; i++) {50 pages.push(i);51 }5253 if (end < totalPages - 1) pages.push("ellipsis-end");5455 pages.push(totalPages);56 return pages;57 };58 const visiblePages = getVisiblePages();59 return (60 <Pagination className="mt-6">61 <PaginationContent className="w-full flex items-center justify-end">62 <PaginationItem>63 <PaginationPrevious64 href="#"65 onClick={(e) => {66 e.preventDefault();67 if (page > 1) {68 setPage(page - 1);69 }70 }}71 className={72 page <= 1 ? "pointer-events-none opacity-50" : "cursor-pointer"73 }74 />75 </PaginationItem>{" "}76 {visiblePages.map((p, i) => {77 if (p === "ellipsis-start" || p === "ellipsis-end") {78 return (79 <PaginationItem key={`ellipsis-${i}`}>80 <PaginationEllipsis />81 </PaginationItem>82 );83 }8485 const pageNum = p as number;86 return (87 <PaginationItem key={i}>88 <PaginationLink89 href="#"90 onClick={(e) => {91 e.preventDefault();92 setPage(pageNum);93 }}94 isActive={page === pageNum}>95 {pageNum}96 </PaginationLink>97 </PaginationItem>98 );99 })}100 <PaginationItem>101 <PaginationNext102 href="#"103 onClick={(e) => {104 e.preventDefault();105 if (page < totalPages) {106 setPage(page + 1);107 }108 }}109 className={110 page >= totalPages111 ? "pointer-events-none opacity-50"112 : "cursor-pointer"113 }114 />115 </PaginationItem>116 </PaginationContent>117 </Pagination>118 );119}4、那么其余的搜索参数该怎么处理呢?还是一样的,先创建一个组件,然后组件接收相应的搜索参数,使用form或者nextjs提供的Form组件来进行路由跳转。
比如说一个输入框搜索组件,可以这样写:
xxxxxxxxxx531// components/SearchForm.tsx2import Form from 'next/form';34type SearchFormProps = {5 currentSearch?: string;6 placeholder?: string;7 className?: string;8};910export default function SearchForm({11 currentSearch = '',12 placeholder = "搜索文章标题...",13 className = "",14}: SearchFormProps) {15 return (16 <Form 17 action="/admin-articles" 18 className={`flex gap-3 ${className}`}19 >20 <input21 type="text"22 name="search"23 defaultValue={currentSearch}24 placeholder={placeholder}25 className="flex-1 px-4 py-3 border border-gray-300 rounded-lg 26 focus:outline-none focus:ring-2 focus:ring-blue-500 27 focus:border-transparent transition-all"28 />29 30 <button31 type="submit"32 className="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white 33 rounded-lg font-medium transition-colors whitespace-nowrap"34 >35 搜索36 </button>3738 {/* 清空按钮(可选) */}39 {currentSearch && (40 <button41 type="button"42 onClick={() => {43 // 注意:这个 onClick 需要 Client 组件,这里我们用普通链接实现44 window.location.href = '/admin-articles';45 }}46 className="px-4 py-3 border border-gray-300 rounded-lg hover:bg-gray-50 text-gray-500"47 >48 清空49 </button>50 )}51 </Form>52 );53}当然,后面可以将input单独抽离出来做成组件,还可以添加select、cascade select等等搜索组件。
5、需要注意的是,https://nuqs.dev/docs/server-side文档里面介绍了两种方法,但只需要使用createLoader方法即可,不要看到createSearchParamsCache就感觉有缓存会更牛逼,还是自己的英语水平不好,不能很好的理解意思
区别:
1、用 useQueryState 代替 React.useState,让状态同时保存在 URL 上。这样做的好处:
xxxxxxxxxx111import { useQueryState, parseAsInteger } from 'nuqs'23const searchParams = {4 page: parseAsInteger.withDefault(1),5}67export const usePage = (options: Options = {}) =>8 useQueryState(9 'page',10 searchParams.page.withOptions({ options, shallow: false })11 )2、需要直接在pagination组件里面接收useQueryState函数的返回值,然后使用状态更新函数来更新search Params,这一点与server端是不同的
注意看,就是使用setPage来翻页的。
xxxxxxxxxx551'use client'23import {4 Pagination,5 PaginationButton,6 PaginationContent,7 PaginationItem,8 PaginationNext,9 PaginationPrevious10} from '@/src/components/ui/pagination'11import React from 'react'1213type PaginationControlsProps = {14 numPages: number;15 page: number;16 setPage: () => void;17}1819// Use client-side hooks to update the page number20// and observe the loading state21export function ClientPaginationControls({22 numPages,23 page,24 setPage,25}: PaginationControlsProps) {26 27 return (28 <Pagination className="not-prose items-center gap-2">29 <PaginationContent>30 <PaginationItem>31 <PaginationPrevious32 disabled={page === 1}33 onClick={() => setPage(p => Math.max(1, p - 1))}34 />35 </PaginationItem>36 {Array.from({ length: numPages }, (_, i) => (37 <PaginationItem key={i}>38 <PaginationButton39 isActive={page === i + 1}40 onClick={() => setPage(i + 1)}41 >42 {i + 1}43 </PaginationButton>44 </PaginationItem>45 ))}46 <PaginationItem>47 <PaginationNext48 disabled={page === numPages}49 onClick={() => setPage(p => Math.min(numPages, p + 1))}50 />51 </PaginationItem>52 </PaginationContent>53 </Pagination>54 )55}3、在组件里面使用usePage(也就是useQueryState函数的返回值)来获取searchParams和状态更新函数,传递给pagination组件。
xxxxxxxxxx181async function ProductPage() {2 const [page,setPage] = usePage();3 const {products, pageCount} = await fetchProducts(page);45 return (6 <>7 <h2>8 Product list9 </h2>10 {products.map(product => (11 <ProductView product={product} key={product.id} />12 ))}13 <Suspense>14 <ClientPaginationControls numPages={pageCount} page={page} setPage={setPage} />15 </Suspense>16 </>17 )18}项目在prisma配置好之后,就要配置better-auth,因为里面有User表,要按照它的来做,所以对后面的很多表都有影响,最先来做是最好的。
Better-Auth 的核心思想是将认证逻辑(Auth Handler, Server)和用户界面逻辑(Auth Client, React Hooks)分离,并利用 Next.js 的路由和中间件进行集成。
我先把整个流程讲解一下,后面再讲解具体操作:
1、数据库模型定义 (Prisma Schema Definition)
这是整个认证系统的基石,作用是告诉 Better-Auth 数据应该以何种结构存储。
| 流程步骤 | 文件示例 | 作用 |
|---|---|---|
| 定义认证相关模型 | prisma/schema.prisma | 🌟 数据结构约定:你定义的 User, Session, Account, 和 Verification 四个核心模型,是 Better-Auth 适配器(Prisma Adapter)用来进行所有数据库操作的蓝图。它决定了用户的身份信息、登录状态、社交账号绑定(GitHub等)以及邮箱验证信息将如何与 PostgreSQL 交互和存储。 |
为什么它很重要?
Better-Auth 依赖于这些特定的模型来处理认证流程:
id, name, email)。providerId 和 accountId),以及邮箱密码的哈希值。为什么按照它的方式来创建这些表呢?我想创建自己的User表不行吗?因为better-auth的原理是这样的:
Better-Auth 的核心实现原理是基于 Tokenless Session Management (无令牌会话管理),结合 Prisma 数据库适配器 来实现全栈身份验证。它遵循了现代 Web 应用中常见的 Session Cookie 机制。
Better-Auth 的三大核心原理
1. Tokenless Session Management(无令牌会话管理)
Better-Auth 避免使用 JWT (JSON Web Tokens) 这种需要在每个请求中传输大量用户数据的传统方式。它的原理更接近于传统的基于 Cookie 的会话:
- 登录时生成会话: 用户登录成功后,Better-Auth 会在数据库的
Session表中创建一个唯一的会话记录。- 颁发安全 Cookie: Better-Auth 然后会创建一个安全的、加密的 Session ID,并将其作为 Cookie 发送到用户的浏览器。这个 Cookie 只包含一个指向数据库中会话记录的随机 ID。
- 验证会话: 在后续的请求中,服务器(包括 Next.js 的服务端组件或 API 路由)通过读取这个 Cookie 中的 ID,然后去数据库中查找完整的会话记录。如果记录存在且未过期,则用户身份得到验证。
- 优势: 这种方式的安全性更高,因为用户数据(如角色、权限)只存储在服务器端的数据库中,而不是暴露在客户端的 Cookie 中。如果需要吊销会话或更改用户权限,可以直接在数据库中操作,即时生效。
2. Prisma Adapter(数据库适配器)
Better-Auth 通过
prismaAdapter将其认证流程与您的 PostgreSQL 数据库连接起来:
- 抽象化操作: Better-Auth 核心库不需要知道您的数据库是 PostgreSQL 还是 MySQL,它只依赖于适配器提供的一组标准 CRUD (增删改查) 函数。
- 模型映射: 适配器负责将 Better-Auth 内部需要的操作(如
createUser,createSession)转换为 Prisma Client 能够理解的、针对您的User,Session,Account等模型的具体查询。3. Next.js 集成机制
Better-Auth 利用 Next.js 的特点,实现服务端和客户端的无缝衔接:
- API Route Handler (
[...all]/route.ts): 这是认证逻辑的入口。Better-Auth 的核心处理函数 (auth.handler) 被封装在 Next.js 的 API 路由中。所有登录、登出、OAuth 回调等操作都通过这个单一路由进行处理。- React Hooks (
useSession): 在客户端,useSessionHook 通过调用 Better-Auth 的 Session API 路由来获取当前会话状态。它管理了 Loading 状态和会话的刷新,使得在 React 组件中获取用户状态变得非常简单。- 服务端 (
auth.api.getSession): 在 Next.js 的 Server Components 或 Middleware 中,可以直接访问原始的 Request Headers 来读取 Session Cookie,然后服务器直接进行数据库查询验证,确保了高性能的 SSR/SSG。简而言之,Better-Auth 的实现原理就是:利用安全的 Session Cookie 作为密钥,通过 Prisma 适配器连接到您的数据库,在服务器端进行身份验证和数据管理,并在 Next.js 的 Server/Client 组件中提供便捷的 Hooks 来消费这些认证状态。
2、后端配置与数据库集成 (Server Setup)
| 流程步骤 | 文件示例 | 作用 |
|---|---|---|
| 初始化 Better-Auth 实例 | lib/auth.ts | 🌟 定义认证规则:这是 Better-Auth 的核心,负责告诉系统如何工作。它配置了:1. 数据库适配器(如 Prisma),用于存储用户、Session 和 Account 数据。2. 认证策略(如 Email/Password, GitHub, Google),用于定义用户登录的方式。 |
3、API 路由集成 (API Handler)
| 流程步骤 | 文件示例 | 作用 |
|---|---|---|
| 创建认证 API 路由 | app/api/auth/[...all]/route.ts | 🌟 处理所有认证请求:这个 Catch-all 路由([...all])将所有发送到 /api/auth/* 的请求(如登录、注册、OAuth 回调、登出等)转发给 Better-Auth 的 Handler 进行处理。它将 Better-Auth 的核心逻辑暴露给 Next.js 应用程序。 |
因为打开页面的时候,nav或header上面的auth-user-menu组件会使用useSession()来查看是否登录了,所以会触发/api/auth/get-session接口调用,这个是better-auth帮我们做的。还有登录、登出的接口,都是这样的形式:/api/auth/xxxxxxxx,所以这里就需要有这个文件,让触发这些接口的时候,转交给better-auth进行处理。
4、前端客户端配置 (Client Setup)
| 流程步骤 | 文件示例 | 作用 |
|---|---|---|
| 创建 Auth Client 实例 | lib/auth-client.ts | 🌟 封装前端交互方法:基于后端配置,创建一个客户端实例。它导出了供 React 组件使用的便捷方法,例如 signIn, signUp, signOut 以及最重要的 useSession Hook。登录、登出、注册、查看是否登录,用到的就是这些方法。这里配置好了之后,在各个组件里面引入使用即可,非常方便。 |
5、前端组件与会话管理 (Usage & Session Management)
| 流程步骤 | 文件示例 | 作用 |
|---|---|---|
| 组件中使用 Hook | app/page.tsx (Client Component) | 🌟 管理用户状态和 UI 切换:通过 useSession() Hook,组件可以获取当前用户的登录状态、用户信息(session.user)和加载状态。这是实现“登录/登出”按钮切换、显示用户名称、以及在客户端保护路由的基础。 |
| 服务端获取 Session | app/dashboard/page.tsx (Server Component) | 🌟 在服务端渲染时获取用户信息:在 Next.js 13/14 的服务端组件中,可以直接调用 auth.api.getSession(),通过读取 Request Headers 中的 Cookie 来预先获取会话信息,实现高效的 SSR 或 SSG。 |
6、编写登录界面
上面的1-4步做好之后,其余的都好说了。
①创建一个登录组件,就是显示登录、登出的button样式的组件,放到nav栏上。里面使用useSession()来查看是否已登录。如果点击登录,就跳转到/sign-in页面;如果点击登出,就调用auth-client里面暴露的signOut方法即可。
②编写app/(auth)/sign-in/page.tsx页面,这个页面UI按照需求编写即可,完全可以是一个普通的form页面。重点是提交表单的时候,要调用auth-client暴露的signIn方法来登录。
③在sign-in页面中,可以加一个跳转链接,跳转到app/(auth)/sign-out/page.tsx页面,这个页面UI也是一个普通的form页面。重点是调用auth-client暴露的signUp方法来注册。
参考https://www.better-auth.com/docs/installation,但是没有专门的nextjs相关的,所以需要结合AI来做。
1、安装依赖npm install better-auth
2、设置环境变量
在.env里面添加:
xxxxxxxxxx21BETTER_AUTH_SECRET=EdIDdrNFl6KnGkF0Hh3WC00lx8h8a3tg2BETTER_AUTH_URL=http://localhost:3000如果需要添加github登录,需要获取GITHUB OAuth凭证后添加:
去 GitHub Developer Settings -> New OAuth App。
Homepage URL: http://localhost:3000
Authorization callback URL: http://localhost:3000/api/auth/callback/github
获取 Client ID 和 Client Secret。
xxxxxxxxxx31# GitHub OAuth2GITHUB_CLIENT_ID=你的Client_ID3GITHUB_CLIENT_SECRET=你的Client_Secret3、创建better-auth实例,配置数据库及prisma适配器
创建/app/lib/auth.ts文件。加入 邮箱密码、GitHub 登录支持和 Prisma 适配器配置。
xxxxxxxxxx251// app/lib/auth.ts23import { betterAuth } from "better-auth";4import { prismaAdapter } from "better-auth/adapters/prisma";5import { prisma } from "./lib/prisma";67export const auth = betterAuth({8 // 配置数据库和prisma适配器9 database: prismaAdapter(prisma, {10 provider: "postgresql", 11 }),12 // 允许邮箱密码登录13 emailAndPassword: { 14 enabled: true, // 启用邮箱密码15 },16 // 社交账号支持,这里启用了github账号的支持17 socialProviders: { 18 github: { 19 clientId: process.env.GITHUB_CLIENT_ID as string, 20 clientSecret: process.env.GITHUB_CLIENT_SECRET as string, 21 }, 22 },23 // 配置密钥24 secret: process.env.BETTER_AUTH_SECRET,25});4、创建better-auth相关的数据库
先安装依赖pnpm add -D @better-auth/cli,这个依赖是用来生成ORM schema的。
然后使用npx @better-auth/cli generate命令来生成model,这个会在现有的schema.prisma里面追加model,可以根据项目需要添加一些字段,但生成的字段最好全部保留。
上面这个方式默认使用的是sqlite3,与postgresql冲突。那么不使用这种方式,想直接定义model,可以参考:https://www.better-auth.com/docs/concepts/database#core-schema,按照里面来定义即可。下面的供参考,还是要检查一下有没有变化:
xxxxxxxxxx761// ==============================2// Better-Auth 模型 (Auth Models)3// ==============================45model User {6 id String @id @default(cuid())7 name String8 email String9 emailVerified Boolean @default(false) @map("email_verified")10 image String?11 role String @default("user") // 添加角色字段,默认值为 "user", "user" / "admin"12 createdAt DateTime @default(now()) @map("created_at")13 updatedAt DateTime @updatedAt @map("updated_at")1415 // Auth 关联16 sessions Session[]17 accounts Account[]1819 // 业务关联20 articles Article[]21 comments Comment[]22 photos Photo[]2324 @@unique([email])25 @@map("user")26}2728model Session {29 id String @id @default(cuid())30 token String31 expiresAt DateTime @map("expires_at")32 ipAddress String? @map("ip_address")33 userAgent String? @map("user_agent")34 createdAt DateTime @default(now()) @map("created_at")35 updatedAt DateTime @updatedAt @map("updated_at")3637 userId String @map("user_id")38 user User @relation(fields: [userId], references: [id], onDelete: Cascade)3940 @@unique([token])41 @@index([userId])42 @@map("session")43}4445model Account {46 id String @id @default(cuid())47 accountId String @map("account_id")48 providerId String @map("provider_id")49 userId String @map("user_id")50 accessToken String? @map("access_token")51 refreshToken String? @map("refresh_token")52 idToken String? @map("id_token")53 accessTokenExpiresAt DateTime? @map("access_token_expires_at")54 refreshTokenExpiresAt DateTime? @map("refresh_token_expires_at")55 scope String?56 password String?57 createdAt DateTime @default(now()) @map("created_at")58 updatedAt DateTime @updatedAt @map("updated_at")5960 user User @relation(fields: [userId], references: [id], onDelete: Cascade)6162 @@index([userId])63 @@map("account")64}6566model Verification {67 id String @id @default(cuid())68 identifier String69 value String70 expiresAt DateTime @map("expires_at")71 createdAt DateTime @default(now()) @map("created_at")72 updatedAt DateTime @updatedAt @map("updated_at")7374 @@index([identifier])75 @@map("verification")76}5、API路由集成
创建/app/api/auth/[...all]/route.ts文件,将代码copy进去即可。这个文件就是帮助我们处理better-auth相关的接口。

6、创建better-auth客户端实例
client实例,可以帮助我们与auth服务进行交互,实例上的signIn方法可以帮助我们登录,signOut方法可以帮助我们登出,useSession方法帮助查询是否已登录等等。
这样我们就不用自己定义API接口了,直接使用这些方法即可。
xxxxxxxxxx91// app/lib/auth-client.ts23import { createAuthClient } from "better-auth/react";45export const authClient = createAuthClient({6 baseURL: process.env.BETTER_AUTH_URL,7});89export const { signIn, signOut, useSession, signUp } = authClient;7、编写登录功能
better-auth客户端实例的方法,使用范例在这里:https://www.better-auth.com/docs/basic-usage。
①创建一个登录组件,就是显示登录、登出的button样式的组件,放到nav栏上。里面使用useSession()来查看是否已登录。如果点击登录,就跳转到/sign-in页面;如果点击登出,就调用auth-client里面暴露的signOut方法即可。
②编写app/(auth)/sign-in/page.tsx页面,这个页面UI按照需求编写即可,完全可以是一个普通的form页面。重点是提交表单的时候,要调用auth-client暴露的signIn方法来登录。
③在sign-in页面中,可以加一个跳转链接,跳转到app/(auth)/sign-out/page.tsx页面,这个页面UI也是一个普通的form页面。重点是调用auth-client暴露的signUp方法来注册。
因为nextjs只能做一些简单项目,所以不要担心更复杂的权限,先把这种权限掌握好即可。
使用next-themes库。设置一个 ThemeProvider 组件来包裹您的应用,并使用 useTheme 钩子来切换主题。这个库能自动处理系统偏好设置,并防止页面闪烁。
1、首先在app/layout.tsx里面添加ThemeProvider。

2、编写一个toggle-mode组件,用来切换主题

3、在nav或header上面添加toggle-mode组件,因为这个组件使用了Provider,所以看上去是在别处操作,但是会作用到全局。这就是Provider的好处。

疑问:next-themes是怎么设置dark mode的呢?
xxxxxxxxxx71<ThemeProvider2 attribute="class"3 defaultTheme="system"4 enableSystem5 disableTransitionOnChange>6 {children}7</ThemeProvider>next-themes 的核心功能是根据当前选择的主题(例如 'light', 'dark', 或 'system')来动态地给根 HTML 元素 (<html>) 添加或移除一个 CSS class。
配置 attribute: 当您在 ThemeProvider 中设置 attribute="class" 时,next-themes 知道要操作 class 属性。
应用 Class:
<html> 标签上添加 class="dark"。dark class,或者根据配置添加 class="light"。CSS/Tailwind 的响应: 您的 CSS 样式(特别是使用 Tailwind CSS 时)会监听这个 class。例如,Tailwind CSS 的 dark: 变体样式仅在父元素或祖先元素有 .dark class 时才会被应用。
这里只谈论GET请求,因为create、update、delete这些请求都会使用server actions来处理,里面会用到useActionState,处理loading和error就比较简单。
我会经常写这样的代码:
xxxxxxxxxx151// 1. 最常见:Server → Client 通过 props(推荐)2async function Page() {3 const data = await db.post.findMany() // 可以是 Date, Map 等复杂类型45 return <PostList posts={data} /> // 直接传!React 19 已经很宽容了6}78// 'use client'9function PostList({ posts }: { posts: Post[] }) {10 return (11 <div>12 {posts.map(p => <PostItem key={p.id} post={p} />)}13 </div>14 )15}那么这里的数据库请求,如果报错了,或者接口很慢,我该怎么处理?
1、使用loading.tsx来处理loading
这是全页面的效果,如果这个页面很大,而页面中别的部分在loading时可能看不到了,所以使用范围有局限。就算使用骨架屏效果,还是不好。
2、使用Streaming + 局部 Suspense来处理loading
这是nextjs v16最推荐的方式,处理的粒度很细。为需要的地方做一个骨架屏效果即可。
xxxxxxxxxx261// app/dashboard/page.tsx2import { Suspense } from 'react'3import PostList from './PostList'45export default function DashboardPage() {6 return (7 <div className="container py-8">8 {/* 静态部分立即显示 */}9 <header className="mb-8">10 <h1 className="text-3xl font-bold">仪表盘</h1>11 <p className="text-muted-foreground">欢迎回来</p>12 </header>1314 {/* 异步数据部分独立 streaming */}15 <Suspense fallback={<PostsSkeleton />}>16 <PostList />17 </Suspense>1819 {/* 其他立即可渲染的内容 */}20 <div className="mt-12">21 <h2>快速统计</h2>22 {/* ... */}23 </div>24 </div>25 )26}3、使用try...catch + error.tsx来处理错误
在获取数据时,使用try...catch来处理可能出现的错误,然后使用error.tsx来展示错误和重试。
但是建议只在当你需要对某些特定错误做精细处理时才写try...catch,普通错误交给error.tsx来处理即可。这是因为 Next.js 官方有意把“严重错误”(网络断开、数据库挂了、代码 bug、超时等)统一交给 error.tsx 处理。还是看项目要求,不要想太多。
xxxxxxxxxx231// app/dashboard/PostList.tsx (加强版)2import { notFound } from 'next/navigation'3import { db } from '@/lib/db'45export default async function PostList() {6 try {7 const posts = await db.post.findMany({8 orderBy: { createdAt: 'desc' },9 take: 20,10 })1112 return <PostListUI posts={posts} />13 } catch (error) {14 // 数据库连接失败、超时等严重错误 → 让 error.tsx 处理15 console.error('数据库查询失败:', error)16 throw error // 抛出,让 error.tsx 捕获17 }18}1920// 或者业务层面的“没找到”21if (!someCriticalData) {22 notFound() // 会显示 40423}1、URL传参
① params传参
场景:每个条目都有独立页面、需要 SEO。对应的是url上的params参数:/products/productId,就是这里的productId。
直接使用params来接收参数
dynamic params
url示例:/products/1。因为params是异步的,所以要使用async...await或者use。
在server components里面获取:
xxxxxxxxxx101// /app/products/[id]/page.tsx23export default async function ProductDetail({4 params5}:{6 params: Promise<{id: string}>7}) {8 const id = (await params).id;9 return ()10}在client components里面获取。client components里面不能使用async...await,所以需要使用react提供的use。
xxxxxxxxxx131// /app/products/[id]/page.tsx23"use client";4import { use } from "react";56export default function ProductDetail({7 params8}:{9 params: Promise<{id: string}>10}) {11 const id = use(params.id);12 return ()13}catch all routes
url示例:/docs/feature1/content1。因为params是异步的,所以要使用async...await或者use。
在server components里面获取:
xxxxxxxxxx101// app/docs/[[...slug]]/page.tsx23export default async function ProductDetail({4 params5}:{6 params: Promise<{slug: string[]}>7}) {8 const slug = (await params).slug || [];9 return ()10}在client components里面获取。client components里面不能使用async...await,所以需要使用react提供的use。
xxxxxxxxxx131// app/docs/[[...slug]]/page.tsx23"use client";4import { use } from "react";56export default function ProductDetail({7 params8}:{9 params: Promise<{slug: string[]}>10}) {11 const id = use(params.slug);12 return ()13}
使用usePathname来接收参数
只能在client components里面使用。返回的是完整的path路径生成的数组。
xxxxxxxxxx141// app/docs/[[...slug]]/page.tsx23"use client";4import { usePathname } from "next/navigation";56export default function ProductDetail() {7 const pathname = usePathname();8 if(pathname.length === 1) {9 return 10 } else if(pathname.length === 2){11 return 12 }13 return ()14}
② searchParams传参
场景:筛选/分页/排序/临时状态。对应的是url上的query参数:?page=3&sort=price-desc。
xxxxxxxxxx241// app/products/page.tsx2import { Suspense } from 'react'3import ProductList from './ProductList'45export default function ProductsPage({6 searchParams,7}: {8 searchParams: { [key: string]: string | string[] | undefined }9}) {10 // Server Component 可以直接读11 const page = Number(searchParams.page) || 112 const sort = searchParams.sort as string || 'newest'13 const q = searchParams.q as string1415 return (16 <div>17 <SearchFilters initialValues={{ q, sort }} />18 19 <Suspense fallback={<ProductsSkeleton />}>20 <ProductList page={page} sort={sort} query={q} />21 </Suspense>22 </div>23 )24}2、props传参
简单数据、复杂数据都可以通过这种方式来传递。
一般的用法是这样的:
xxxxxxxxxx151// 1. 最常见:Server → Client 通过 props(推荐)2async function Page() {3 const data = await db.post.findMany() // 可以是 Date, Map 等复杂类型45 return <PostList posts={data} /> // 直接传!React 19 已经很宽容了6}78// 'use client'9function PostList({ posts }: { posts: Post[] }) {10 return (11 <div>12 {posts.map(p => <PostItem key={p.id} post={p} />)}13 </div>14 )15}但是如果使用了Suspense,那么就可以使用use来解包Promise。这种是v16时代经典。
xxxxxxxxxx211// 2. 流式 + Promise 写法(非常推荐!Next.js 16 时代经典)2async function Page() {3 const postsPromise = db.post.findMany() // 不 await!45 return (6 <>7 <Suspense fallback={<Loading />}>8 <PostList postsPromise={postsPromise} />9 </Suspense>10 <OtherStaticContent />11 </>12 )13}1415// 'use client'16import { use } from 'react'1718function PostList({ postsPromise }: { postsPromise: Promise<Post[]> }) {19 const posts = use(postsPromise) // 在客户端组件里解包 Promise20 // ... 渲染21}3、通过Context传参
这个经常使用,读取和操作数据非常方便。但是只能在client components里面使用。
xxxxxxxxxx251// 3. Context(只能在 Client Component 树中使用)2'use client'34import { createContext, useContext, useState } from 'react'56const ThemeContext = createContext<any>(null)78export function ThemeProvider({ children }: { children: React.ReactNode }) {9 const [theme, setTheme] = useState('dark')10 return (11 <ThemeContext.Provider value={{ theme, setTheme }}>12 {children}13 </ThemeContext.Provider>14 )15}1617export function useTheme() {18 return useContext(ThemeContext)19}2021// 使用(必须在 Provider 下面)22function Button() {23 const { theme } = useTheme()24 // ...25}4、zustand、redux等第三方库传参
这个暂时用不到。
可以使用简单的modal来做,但是最标准的做法就是做成页面。那么项目结构是什么样的呢?
xxxxxxxxxx111app/2└── (dashboard)/3└── photos/4├── page.tsx # 照片列表5├── new/ # 新增页面6│ └── page.tsx7└── [id]/ # 编辑页面8└── edit/9└── page.tsx10└── _components/ # 关键:提取共用的表单组件11└── photo-form.tsx
因为新增、编辑页面大部分内容都是一样的,所以需要提取一个公用的表单组件:
xxxxxxxxxx131export default function PhotoForm({ initialData }) {2 3 // 根据这个来判断是编辑还是新增4 const isEdit = !!initialData;5 6 return (7 <form>8 {/* 统一的样式,统一的 motion 动画 */}9 <input defaultValue={initialData?.title} />10 <button>{isEdit ? "更新照片" : "发布照片"}</button>11 </form>12 );13}实现方式:
new/page.tsx 直接渲染 <PhotoForm />。[id]/edit/page.tsx 先获取数据,然后渲染 <PhotoForm initialData={data} />。将项目部署到vercel上,其实很简单。有几点需要注意:
1、prisma generate生成的内容,需要上传到git。如果在gitignore里面忽略掉了,需要删除忽略项。
2、如果使用了better-auth,那么需要在github OAUTH APP里面,既生成链接为localhost:3000的app,也要生成网站上线后的网址为链接的app。等于说是要生成2个。可以都放到.env里面去管理。

并且上线后的系统变量里面的BETTER_AUTH_URL=https://atlove.top,要改为具体的网址。
3、如果使用的是具体的安装包的工具,比如说想明确是npm、yarn还是pnpm,那么在部署的时候,要指定具体的运行命令。
4、如果想要绑定域名,那么就需要在settings里面,先添加域名。



添加了域名还不够,还需要将这个域名绑定到cloudflare上面去。


我使用的是nextjs项目创建时,默认的layout.tsx的内容,里面的body的className上指定的是默认的内容,但是在chrome和firefox上的字体就明显不同,因为我一直是在chrome上进行调试的,所以没有发现这个问题,等到后面上线了,我在firefox上打开博客,发现字体不是我想要的效果。


为什么 Chrome 和 Firefox 表现不同?
font-family 指令,所以回退到了浏览器的默认衬线字体(通常是 Times New Roman),导致看起来像报纸一样的衬线体。原因:
虽然你在 body 上添加了 ${geistSans.variable},但这只是定义了一个 CSS 变量(--font-geist-sans),并没有告诉浏览器“整个页面都要使用这个字体”。
解决方法:
修改 RootLayout,将 geistSans.className 加入到 body 中:
xxxxxxxxxx161// RootLayout.tsx2export default function RootLayout({3 children,4}: Readonly<{5 children: React.ReactNode;6}>) {7 return (8 <html lang="en">9 <body10 className={`${geistSans.className} ${geistMono.variable} antialiased`}11 >12 {children}13 </body>14 </html>15 );16}geistSans.className 而不是 .variable。这会自动生成一个包含 font-family: 'Geist' 的 CSS 类。我在做博客项目的时候,about页面有动画,我想要的效果是每次进入这个页面都会显示动画。但是只有第一次进入时显示了动画。
这通常是因为 Next.js 的客户端路由缓存(Client-side Cache)机制 导致的。
当你第一次进入 /about 页面时,Next.js 会完整挂载该组件,从而触发 CSS 或 Framer Motion 定义的入场动画。但当你点击 Header 里的 Link 切换到其他页面再切回来时,Next.js 会尽量重用已经加载的组件实例以提升性能,导致组件没有经历完整的“销毁 -> 重新挂载”过程,动画自然就不会再次触发。
解决方法:
为根容器添加唯一的 key
这是 Next.js 中强制触发重绘最简单的方法。通过给 About 页面的外层容器绑定 usePathname,让 React 认为“路径变了,这是一个全新的组件”。
xxxxxxxxxx211"use client";23import { usePathname } from "next/navigation";4import { motion } from "framer-motion"; // 假设你使用 framer-motion56export default function AboutPage() {7 const pathname = usePathname();89 return (10 // 使用 key={pathname} 强制每次切换路由时重新挂载组件11 <motion.div 12 key={pathname} 13 initial={{ opacity: 0, y: 20 }}14 animate={{ opacity: 1, y: 0 }}15 className="p-8"16 >17 <h1>About Me</h1>18 {/* 页面内容 */}19 </motion.div>20 );21}
现象:
我的后台数据fitness更新后,routines数据更新了(也就是setRoutines正常),但是好像zod里面的数据没有更新。现象是timer-circle里面的一圈还是5分钟,实际数据是15分钟,这样倒计时运行的时候,5分钟就转了一圈,总共转了3圈。运行了第一个EXERCISE之后卡死了,显示init routines,按F5刷新之后就好了。
为什么会造成这种现象?
我在fitness action里面,都有这样的代码:
xxxxxxxxxx21revalidatePath("/admin-fitness");2revalidateTag("routines", "max");为什么数据还是有问题呢?
revalidatePath / revalidateTag 的作用范围有限
persist 把状态存到了 localStorage。revalidateTag("routines", "max") 在 Next.js 16 中更多是 stale-while-revalidate 语义(先返回旧数据,后台悄悄刷新),而不是立即强制刷新。所以,涉及到zustand数据更新的地方,需要手动进行操作。
总结一句话:
revalidatePath 和 revalidateTag 只能让下一次服务器请求拿到新数据,但无法清除客户端已经持久化在 localStorage 里的 Zustand 状态。这就是你改完后台后不刷新就出问题的核心原因。
比如说我的第一个exercise里面的时间,之前是900s。然后在后台我将其改为了75s,回到fitness页面之后,返回的routines数据是更新了,但是activeRoutine里面的steps数据没有更新。

解决办法:
在RoutinesInitializer里面,我是这样判断的:如果没有activeRoutineId,那么就设置活动routine。但是后台更新之后,activeRoutineId没有变化,还是缓存里面的数据。
顺便说一下,这里的routines,在后台数据更新后,拿到的是最新的数据,所以和cache components及其缓存是没有关系的。
xxxxxxxxxx291"use client";23import { RoutineWithWorkout } from "@/lib/types";4import { useWorkoutStore } from "@/stores/workoutStore";5import { useEffect } from "react";67interface RoutinesInitializerProps {8 routines: RoutineWithWorkout[];9 children: React.ReactNode;10}1112export default function RoutinesInitializer({13 routines,14 children,15}: RoutinesInitializerProps) {16 const { setRoutines, activeRoutineId, setActiveRoutine } = useWorkoutStore();1718 useEffect(() => {19 if (routines?.length > 0) {20 setRoutines(routines);21 if (!activeRoutineId) {22 setActiveRoutine(routines[0].id);23 }24 }25 }, [routines, activeRoutineId, setRoutines, setActiveRoutine]);2627 return <>{children}</>;28}29我想到的解决办法是:在setRoutines时,判断后台返回的routines数据和缓存里面的数据是不是一样的,如果是一样的,那么直接赋值;如果不是一样的,那么就设置activeRoutineId为null,这样就可以更新setActiveRoutine了。
判断数据是否一致,不建议使用JSON.stringify()方法,因为性能很差。这里使用数组的some方法,只要有一个为true,就会返回true,性能更好。
xxxxxxxxxx201setRoutines: (routines) => {2 const originalRoutines = get().routines;34 const hasChanged =5 originalRoutines.length !== routines.length ||6 originalRoutines.some((old, index) => {7 const nw = routines[index];8 return (9 !nw || old.id !== nw.id || old.totalDuration !== nw.totalDuration10 );11 });1213 if (Array.isArray(routines) && routines.length > 0) {14 if (hasChanged) {15 set({ routines, activeRoutineId: null });16 } else {17 set({ routines });18 }19 }20}所以说,遇到问题,最好还是先找自己代码的问题,不要动不动就说nextjs框架怎么样,怎么不好用。说不定就是自己代码有问题,考虑不周。