Next.js 16 + Supabase 的开源全栈 CMS:NextBlock 实战解析

Next.jsSupabaseCMS
于 2026-08-28 04:05:31 修改
·本内容遵循CC 4.0 BY-SA版权协议

当业务里需要一套轻量、可控的内容管理系统,又不想被重型 CMS 绑定的时候,开源项目往往是最合适的切入点。最近在调研 Next.js 全栈方案时,看到一个很有意思的项目:NextBlock CMS。它把 Next.js 16 和 Supabase 组合成一套开源的全栈 CMS,定位很清晰:不重复造轮子,利用 Next.js 的 App Router 能力,加上 Supabase 的 Postgres、Auth 和 Storage,快速搭建自带后台的内容站点。

本文就从实际使用角度,拆解 NextBlock CMS 的核心设计、环境准备、部署流程、二次开发方式,以及 Supabase Storage 接入和常见坑点。适合正在选型 CMS、想自己维护内容平台,或者准备用 Next.js + Supabase 做全栈项目的开发者阅读。

1. 背景与核心概念

1.1 什么是 NextBlock CMS

NextBlock CMS 是一套基于 Next.js 16 的开源全栈 CMS(Content Management System,内容管理系统)。它不是一个类似 WordPress 的独立服务,而是把内容管理能力以代码和数据结构的方式集成进 Next.js 应用里,让开发者既能获得 CMS 的编辑体验,又能保留 Next.js 的灵活性和部署方式。

这里有两个关键词值得拆开看:

  • Next.js 16:当前版本已经全面拥抱 App Router 架构,支持服务端组件、服务端渲染、静态生成和增量静态再生成。NextBlock CMS 的内容渲染主要依赖服务端能力,因此内容的读取性能、SEO 友好度都有天然优势。
  • Supabase:开源 Firebase 替代方案,底层是 PostgreSQL,提供了数据库、用户认证、存储、实时订阅和后端函数。CMS 的数据结构本质上是结构化内容,用 Postgres 存储非常合理;而图片、附件这类二进制文件,则交给 Supabase Storage。

简单来说,NextBlock CMS = Next.js 负责页面和渲染,Supabase 负责数据和文件,两者结合构成一个「代码即配置」的内容管理系统。

1.2 它解决什么问题

传统 CMS 开发中经常遇到的几个痛点:

  1. 内容模型搭建麻烦。很多 CMS 的后台拖拽非常灵活,但真要自定义字段和数据结构时,反而受平台约束。
  2. 前后端割裂。内容数据存在 A 系统,页面渲染在 B 系统,接口联调和权限控制都很繁琐。
  3. 部署链路重。传统 PHP CMS 需要配置服务器、数据库、Web Server,维护成本高。
  4. 技术栈不统一。开发人员需要在多个语言和框架之间反复切换。

NextBlock 的思路是把内容模型直接定义在代码里,数据库结构由迁移脚本或 Supabase 控制,渲染层完全交给 Next.js。这样的好处是:

  • 内容结构版本可控,可以放进 Git 仓库审查。
  • 数据模型扩展不需要额外后台系统,写代码即可。
  • 页面渲染和内容管理在同一个项目里,部署就是一次 Serverless 部署。

1.3 典型应用场景

根据项目定位和生态,以下几个场景最适合使用 NextBlock CMS:

场景 适用原因
技术博客 / 文档站 需要 Markdown 或富文本内容管理,同时对 SEO 要求高
企业官网 页面区块化,运营人员可维护首页内容和新闻动态
个人作品集 数据结构灵活,不需要复杂的权限体系
SaaS 产品帮助中心 内容分版本管理,与产品发版节奏一致
内部知识库 利用 Supabase Auth 做员工登录和权限控制

需要注意,NextBlock CMS 更像「面向开发者的轻量 CMS」,不是面向不懂技术的运营人员提供的所见即所得平台。它的目标用户仍是开发团队。

1.4 为什么要了解这类项目

现在 CMS 领域呈现两种方向:一种是 Strapi、Directus 这种偏重后台管理的「无头 CMS」;另一种是 Next.js + headless CMS 的前后端分离方案。而 NextBlock CMS 走的是一条更极客的路线:内容管理、数据库、文件存储、认证全部用开源服务组装,再以代码仓库作为唯一事实来源。这种思路的价值在于可控性和可迁移性——项目哪天不维护了,代码和数据库结构还在你自己手里,完全可以平移到其他技术栈。

另外,从学习角度看,这类项目也是理解全栈开发的好范本。它涉及服务端渲染、数据库表设计、文件存储策略、API 路由、权限控制、部署配置,几乎覆盖了现代 Web 开发的完整链路。

2. 环境准备与版本说明

2.1 开发环境要求

在开始之前,我们先确认本机环境。由于 NextBlock CMS 是较新的开源项目,且目标环境是 Next.js 16,建议按以下版本准备:

软件 建议版本 说明
Node.js 20.x 或更高 Next.js 16 需要较新的 Node 运行时
npm / pnpm 最新稳定版 包管理器,pnpm 对 monorepo 支持更好
Git 最新版 拉取代码和版本管理
Supabase CLI 最新版 本地开发数据库和远程项目管理
Docker 可选 Supabase CLI 本地启动依赖 Docker
代码编辑器 VS Code / Cursor 安装 nextjs、sql、docker 插件更顺手

版本需要根据你的项目实际情况调整,本文示例以常见环境为例,重点演示配置思路。Next.js 16 的正式特性以官方发布为准,如果使用的是 Next.js 15,部分 API 可能有差异,需要按实际版本调整。

2.2 Supabase 账号准备

NextBlock CMS 的核心数据层依赖 Supabase,所以需要提前准备:

  1. 访问 supabase.com 注册账号。
  2. 创建一个新组织(Organization)。
  3. 在组织下创建新项目,记录下项目的 URL 和 anon / service_role 两个 key。
  4. 安装 Supabase CLI,用于本地开发环境。

本地开发时,可以使用 Supabase CLI 启动一套完整的本地服务,包括 Postgres 数据库、Auth、Storage 和 API:

BASH
supabase init
supabase start

执行 supabase start 后,CLI 会启动本地容器并在终端输出本地 API 地址和 key。示例输出类似:

TEXT
Started supabase local development setup.
 
API URL: http://localhost:54321
GraphQL URL: http://localhost:54321/graphql/v1
DB URL: postgresql://postgres:postgres@localhost:54322/postgres
Studio URL: http://localhost:54323
Inbucket URL: http://localhost:54324
anon key: eyJhbGciOi...
service_role key: eyJhbGciOi...

这里需要在 .env.local 中配置。

2.3 Next.js 项目基础概念

在接触 NextBlock CMS 之前,建议大家对 Next.js 的 App Router 有基本了解。几个核心概念:

app/ 目录:Next.js 13 之后引入的目录约定。app/page.tsx 对应首页路由,app/blog/[slug]/page.tsx 对应动态路由。

服务端组件与客户端组件:默认情况下,App Router 中的组件是服务端组件,可以直接访问数据库和文件系统。如果组件里需要 useStateuseEffect 或浏览器事件,就需要在文件顶部加 "use client" 指令。

API 路由:在 App Router 中,app/api/xxx/route.ts 可以定义 GET、POST、PUT、DELETE 等接口。

Server Actions:允许在服务端直接修改数据的异步函数,常用来处理表单提交,适合 CMS 的内容创建和更新场景。

NextBlock CMS 的很多设计都在这些概念之上展开。理解它们之后,阅读项目源码会轻松很多。

3. NextBlock CMS 核心架构拆解

3.1 整体架构图

不画复杂的架构图,用文字描述整体的数据流:

TEXT
浏览器
↓ 访问页面
Next.js Server(App Router)
↓ 读取内容
Supabase Postgres(内容数据)
↓ 读取/上传文件
Supabase Storage(图片、附件)
↓ 验证身份
Supabase Auth(登录、权限)

内容发布流程:

TEXT
运营/开发人员
↓ 在 Next.js 项目中编写或更新内容文件
↓ 通过 Server Action / API 写入
Supabase Postgres
↓ 用户访问
Next.js 服务端渲染页面,从 Postgres 查询内容,从 Storage 加载图片

这里要注意,NextBlock CMS 的内容存储和数据模型是放在 Supabase 里的,不是像 MDX 方案那样把内容写在本地文件里。这样设计的好处是内容更新不需要重新构建项目,适合网站部署后继续被运营人员维护的场景。

3.2 Post 内容模型设计

一个 CMS 最核心的数据模型就是「文章(Post)」。参考常见的 CMS 设计,以及 NextBlock 这类项目的思路,Post 表大致包含以下字段:

字段 类型 说明
id uuid 主键,默认 gen_random_uuid()
title text 标题
slug text 访问路径,需唯一
excerpt text 摘要或描述
content jsonb 正文内容,以 JSON 结构承载块数据
cover_image text 封面图地址
featured boolean 是否精选
status text 草稿/已发布
author_id uuid 作者,关联用户表
published_at timestamptz 发布时间
created_at timestamptz 创建时间
updated_at timestamptz 更新时间

其中 content 使用 jsonb 类型,是 NextBlock 这类区块化 CMS 的关键设计。jsonb 可以存储任意 JSON 结构,不需要为每种内容类型单独建表。

3.3 Block 区块模型

NextBlock CMS 的 Block 可以理解为内容页面的「积木」。比如一篇博客文章可能包含:

  • 标题块
  • 段落块
  • 图片块
  • 代码块
  • 引用块
  • 嵌入视频块
  • 自定义组件块

每个 Block 在 content 字段中对应一个 JSON 对象,结构类似:

JSON
{
"blocks": [
{
"id": "block-1",
"type": "heading",
"props": {
"level": 2,
"text": "这是一级标题"
}
},
{
"id": "block-2",
"type": "paragraph",
"props": {
"text": "这一段是正文内容。"
}
},
{
"id": "block-3",
"type": "image",
"props": {
"src": "https://your-project.supabase.co/storage/v1/object/public/images/cover.png",
"alt": "封面图"
}
}
]
}

渲染的时候,Next.js 组件根据 type 字段匹配对应的渲染组件,把 props 传进去。

这种设计有几个明显优点:

  1. 扩展性强。要新增一种内容块,只需要新增一个组件并注册类型,数据库不需要迁移。
  2. 内容结构清晰。复杂页面可以被拆成可组合的区块,每个区块的职责单一。
  3. 前后端一致。编辑器的输出结构就是渲染层的输入结构,避免复杂的转换层。

对于站点开发者来说,这也是 NextBlock CMS 最有吸引力的地方:内容模型不再是「填空题」,而是「搭积木」。

3.4 Supabase 在架构中的职责

Supabase 在 NextBlock CMS 中承担了三个核心角色:

Postgres 数据库:存储 Post、Block、媒体文件元数据、用户信息等结构化数据。通过 PostgREST 自动生成 RESTful API,也支持直接通过 supabase-js SDK 访问。

Supabase Auth:提供用户注册、登录、Session 管理。CMS 后台需要区分普通访客和内容管理员,Auth 是权限控制的基础。

Supabase Storage:存储图片、视频、附件等二进制文件。Storage 通过 Bucket(存储桶)组织文件,可以设置公开或私有访问权限。

这三个服务之间是联动的。例如:上传图片时,前端需要先取得登录用户的 access_token,上传到 Storage;之后把 Storage 返回的文件路径写入 Post 表的 cover_image 字段。整个过程涉及认证、存储、数据库三部分,缺一不可。

4. 完整实战:搭建 NextBlock CMS 并接入 Supabase

下面我们实际操作一遍,从克隆项目到本地运行,再完成基本的配置和内容发布。由于 NextBlock CMS 可能持续迭代,示例中的命令和文件路径请以仓库 README 为准。这里主要演示完整的配置思路。

4.1 克隆项目并安装依赖

首先拉取项目代码:

BASH
git clone https://github.com/your-repo/nextblock.git
cd nextblock

建议使用 pnpm 安装依赖,速度更快,对 monorepo 支持也更好:

BASH
pnpm install

安装完成后,查看项目结构。一般情况下,项目会包含以下关键目录:

TEXT
nextblock/
├── app/
│ ├── (site)/
│ │ ├── page.tsx # 首页
│ │ └── posts/
│ │ └── [slug]/ # 文章详情页
│ └── admin/
│ ├── posts/
│ └── settings/
├── components/
│ └── blocks/ # 区块渲染组件
├── lib/
│ ├── supabase/
│ │ ├── client.ts # 浏览器端 Supabase 客户端
│ │ └── server.ts # 服务端 Supabase 客户端
│ └── posts.ts # 内容查询逻辑
├── supabase/
│ ├── migrations/ # 数据库迁移脚本
│ └── schema.sql # 建表 SQL
├── .env.example
└── package.json

如果你看到的目录略有不同,不用紧张,重点理解配置文件和服务端工具的用途即可。

4.2 配置环境变量

项目根目录下创建 .env.local 文件。参考 .env.example 的变量名,最核心的内容包括:

BASH
# .env.local
NEXT_PUBLIC_SUPABASE_URL=http://localhost:54321
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
NEXT_PUBLIC_SITE_URL=http://localhost:3000

变量说明:

  • NEXT_PUBLIC_SUPABASE_URL:浏览器端可以访问的 Supabase API 地址。本地用 CLI 启动时,地址是 http://localhost:54321;线上就是你创建项目的 URL,格式类似 https://xxxx.supabase.co
  • NEXT_PUBLIC_SUPABASE_ANON_KEY:匿名 key,用于客户端初始化 Supabase SDK。
  • SUPABASE_SERVICE_ROLE_KEY:服务端 key,拥有最高权限,可以绕过 RLS(Row Level Security),只允许在服务端使用,绝不能暴露到浏览器端。
  • NEXT_PUBLIC_SITE_URL:站点的对外地址,用于生成绝对路径链接。

这里的核心安全原则是:NEXT_PUBLIC_ 开头的变量在浏览器可见,其他变量只在服务端可见。任何含有敏感权限的 key 都不能加 NEXT_PUBLIC_ 前缀。

4.3 创建数据库表结构

如果你使用 Supabase CLI,可以把建表语句放到 supabase/migrations/ 目录下。以下是一个最小化的 Post 建表 SQL 示例,演示了核心字段和 RLS 策略配置。

先创建一个迁移文件:

BASH
# supabase/migrations/20250101000000_create_posts.sql

写入内容:

SQL
-- 开启 UUID 扩展
create extension if not exists "uuid-ossp";
 
-- 创建文章表
create table if not exists public.posts (
id uuid primary key default uuid_generate_v4(),
title text not null,
slug text not null unique,
excerpt text,
content jsonb not null default '{"blocks": []}'::jsonb,
cover_image text,
featured boolean not null default false,
status text not null default 'draft',
author_id uuid references auth.users(id) on delete set null,
published_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
 
-- 创建更新时间触发器
create or replace function public.handle_updated_at()
returns trigger as $$
begin
new.updated_at = now();
return new;
end;
$$ language plpgsql;
 
create trigger set_updated_at
before update on public.posts
for each row
execute procedure public.handle_updated_at();
 
-- 开启 RLS
alter table public.posts enable row level security;
 
-- 所有人可以读已发布内容
create policy "public_read_published_posts"
on public.posts
for select
using (status = 'published' and published_at <= now());
 
-- 仅管理员可写,这里用 role 判断
create policy "admin_insert_posts"
on public.posts
for insert
with check (auth.role() = 'authenticated');
 
create policy "admin_update_posts"
on public.posts
for update
using (auth.role() = 'authenticated');
 
create policy "admin_delete_posts"
on public.posts
for delete
using (auth.role() = 'authenticated');

这段 SQL 有几点需要解释:

  1. RLS 必须开启。PostgREST 会自动遵守行级安全策略。如果表没有开启 RLS,Supabase 的 anon key 可能直接读到你不想公开的数据。
  2. 读策略和写策略分离。访客只能读取 published 状态的文章,登录用户只能写入,但还不能修改他人的内容。如果要精细化权限,可以在 policy 中加入 author_id = auth.uid() 条件。
  3. 触发器自动更新 updated_at。这是非常实用的工程细节,避免每次更新都要在业务代码里手动修改时间。

本地应用迁移:

BASH
supabase db push

如果是远程项目,可以执行:

BASH
supabase db push --linked

注意,数据库的 DDL 操作建议先在本地或测试环境执行并备份,不要直接在线上生产库执行没有验证过的 SQL。

4.4 创建 Storage Bucket

图片文件需要存储到 Supabase Storage。先在本地或云端创建一个公开的 images bucket。

用 Supabase Dashboard 操作:进入 Storage → New bucket → 输入 images → 选择 Public。

或者用 SQL 创建:

SQL
insert into storage.buckets (id, name, public)
values ('images', 'images', true);

创建 bucket 之后,还需要配置 Storage 的访问策略。一般来说,图片场景希望所有人可读,但只有登录用户可上传。可以通过 Storage 的 Policy 配置实现。

下面是在 Supabase Dashboard 中建议配置的 RLS 策略:

  • 公开读:允许所有用户读取 images bucket。
  • 登录用户可上传:仅 authenticated 角色可执行 insert 操作。
  • 登录用户可删除自己的文件:上传路径中包含用户 ID,删除时校验 storage.foldername(name) 的第一个字段等于 auth.uid()

最安全的实践是:对上传请求做文件类型校验,只允许图片格式;同时限制文件大小。生产环境中还可以在服务端用 service_role key 校验文件类型后再更新记录。

4.5 初始化 Supabase 客户端

Next.js 中需要区分服务端和客户端两个 Supabase 实例。

服务端客户端位于 lib/supabase/server.ts

TS
// 文件路径:lib/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
 
export async function createClient() {
const cookieStore = await cookies();
 
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
// 在 Server Component 中调用 cookieStore.set 会抛出异常,
// 这里只需要在 Server Action 或 Route Handler 中执行即可。
}
},
},
}
);
}

客户端客户端位于 lib/supabase/client.ts

TS
// 文件路径:lib/supabase/client.ts
"use client";
 
import { createBrowserClient } from "@supabase/ssr";
 
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}

使用 @supabase/ssr 而不是 @supabase/supabase-js 直接创建客户端,是因为服务端组件需要读取和设置 cookie,通过 Next.js 的 cookie 管理机制来维护 session。

4.6 查询文章列表

在服务端组件中查询已发布的文章列表:

TS
// 文件路径:lib/posts.ts
import { createClient } from "./supabase/server";
 
export async function getPublishedPosts() {
const supabase = await createClient();
 
const { data, error } = await supabase
.from("posts")
.select("id, title, slug, excerpt, cover_image, published_at")
.eq("status", "published")
.lte("published_at", new Date().toISOString())
.order("published_at", { ascending: false });
 
if (error) {
console.error("Error fetching posts:", error);
return [];
}
 
return data;
}

查询单个文章:

TS
// 文件路径:lib/posts.ts
export async function getPostBySlug(slug: string) {
const supabase = await createClient();
 
const { data, error } = await supabase
.from("posts")
.select("*")
.eq("slug", slug)
.eq("status", "published")
.single();
 
if (error) {
return null;
}
 
return data;
}

这里 select("*") 是为了拿到完整的 content JSON 字段,用于后面的区块渲染。

4.7 渲染 Block 内容

拿到文章的 content 字段后,需要在页面中渲染各个区块。

app/posts/[slug]/page.tsx

TSX
// 文件路径:app/posts/[slug]/page.tsx
import { getPostBySlug } from "@/lib/posts";
import { notFound } from "next/navigation";
import { BlockRenderer } from "@/components/blocks/block-renderer";
 
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPostBySlug(slug);
 
if (!post) {
notFound();
}
 
return (
<article>
<header>
<h1>{post.title}</h1>
<p>{post.excerpt}</p>
</header>
 
<BlockRenderer blocks={post.content.blocks || []} />
</article>
);
}

BlockRenderer 组件负责根据 type 分发到对应的渲染组件。

TSX
// 文件路径:components/blocks/block-renderer.tsx
import { HeadingBlock } from "./heading-block";
import { ParagraphBlock } from "./paragraph-block";
import { ImageBlock } from "./image-block";
import { CodeBlock } from "./code-block";
 
const blockComponents = {
heading: HeadingBlock,
paragraph: ParagraphBlock,
image: ImageBlock,
code: CodeBlock,
} as const;
 
export function BlockRenderer({ blocks }: { blocks: any[] }) {
return (
<>
{blocks.map((block) => {
const Component = blockComponents[block.type as keyof typeof blockComponents];
 
if (!Component) {
return <p key={block.id}>未知区块类型:{block.type}</p>;
}
 
return <Component key={block.id} {...block.props} />;
})}
</>
);
}

这里把 blocks 的类型定义成 any[] 只是为了快速演示,真实项目中建议为 JSON 结构编写 TypeScript 类型和运行时校验,避免后端数据结构变化导致页面崩溃。

对应区块组件示例:

TSX
// 文件路径:components/blocks/heading-block.tsx
export function HeadingBlock({
level,
text,
}: {
level: 1 | 2 | 3;
text: string;
}) {
const Tag = `h${level}` as keyof JSX.IntrinsicElements;
 
return <Tag>{text}</Tag>;
}
TSX
// 文件路径:components/blocks/paragraph-block.tsx
export function ParagraphBlock({ text }: { text: string }) {
return <p>{text}</p>;
}

这样一个最简单的 Block 渲染链路就通了。后续要新增图片块、代码块之外的自定义内容类型,只需要在 blockComponents 注册一个组件,然后在 content.blocks 数组中添加对应 JSON 即可。

4.8 通过 Server Action 创建文章

CMS 的核心能力之一是内容创建。在 Next.js 中,可以通过 Server Action 直接操作数据库。

app/admin/actions.ts

TS
// 文件路径:app/admin/actions.ts
"use server";
 
import { createClient } from "@/lib/supabase/server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
 
export async function createPost(formData: FormData) {
const supabase = await createClient();
 
const title = formData.get("title") as string;
const slug = formData.get("slug") as string;
const excerpt = formData.get("excerpt") as string;
const content = JSON.parse(formData.get("content") as string);
 
// 校验登录状态
const {
data: { user },
} = await supabase.auth.getUser();
 
if (!user) {
throw new Error("未登录用户不能创建文章");
}
 
const { error } = await supabase.from("posts").insert({
title,
slug,
excerpt,
content,
status: "published",
author_id: user.id,
published_at: new Date().toISOString(),
});
 
if (error) {
throw new Error(`创建文章失败: ${error.message}`);
}
 
revalidatePath("/");
redirect(`/posts/${slug}`);
}

这个例子没有校验 slug 是否重复,也没有处理图片上传。真实项目中,你需要在 Server Action 里完成:

  1. 校验表单字段是否合法。
  2. 校验当前用户是否有权限创建文章。
  3. 检查 slug 是否冲突。
  4. 处理图片文件,上传到 Supabase Storage。
  5. 使用事务或回调确保数据一致性。

Server Action 的方式降低了前后端联调成本,但要注意:服务端代码不要盲目信任用户输入,所有字段都需要做服务端校验,尤其是 content 字段的 JSON 内容。

4.9 启动本地开发服务器

所有配置完成后,启动本地开发环境:

BASH
pnpm dev

浏览器访问 http://localhost:3000,能看到首页文章列表。进入 http://localhost:3000/admin,可以管理文章。

如果页面显示空白或报错,需要检查:

  1. 本地 Supabase 服务是否还在运行。
  2. .env.local 里的 key 是否匹配本地输出。
  3. 数据库迁移是否成功。
  4. RLS 策略是否阻止了匿名读取。

下面我们专门用一节来整理常见问题。

5. 常见问题与排查思路

在部署和二次开发 NextBlock CMS 时,最容易遇到以下几类问题。

5.1 RLS 导致文章读取不到

问题现象:首页或文章详情页显示内容为空,控制台没有任何 Next.js 服务端错误,但数据库里明明有文章数据。

常见原因:Post 表的 RLS 策略没有配置,或者配置错误。Supabase 开启了 RLS 的表,如果没有任何 policy,PostgREST 会拒绝所有请求,包括 anon key 的读请求。

解决思路

  1. 进入 Supabase Dashboard → Authentication → Policies,检查 posts 表的 policy。
  2. 确认存在 for select using (status = 'published') 的策略。
  3. 如果策略没有生效,可以在 SQL Editor 中执行 drop policy if exists 后重建。

预防方案:在本地用 supabase db push 推送迁移时,先用 supabase db reset 重建数据库,通过 SQL 脚本保证 RLS 策略始终存在。

5.2 Supabase Storage 上传返回 403

问题现象:登录用户上传图片时,Supabase Storage 返回 403 Forbidden。

常见原因:Storage bucket 的插入策略没有配置,或上传请求没有附带认证 header。

解决思路

  • 检查 Storage → Policies,确认存在允许 authenticated 角色 insert 的策略。
  • 检查上传代码是否通过 Supabase SDK 的 storage.from("images").upload() 方法调用,SDK 会自动附带认证信息。
  • 如果使用独立 fetch 请求,需要手动在 header 中加入 Authorization: Bearer <access_token>

预防方案:优先使用 Supabase SDK 操作 Storage,不要自己拼 API 请求。

5.3 Next.js 生产环境无法读取环境变量

问题现象:本地开发正常,但部署到 Vercel 后,动态功能失效,Supabase 连接失败。

常见原因:部署平台的环境变量没有配置,或者只配置了本地 .env.local,没有同步到云端。

解决思路

在 Vercel 项目设置 → Environment Variables 中,逐一添加:

  • NEXT_PUBLIC_SUPABASE_URL
  • NEXT_PUBLIC_SUPABASE_ANON_KEY
  • SUPABASE_SERVICE_ROLE_KEY
  • NEXT_PUBLIC_SITE_URL

注意 SUPABASE_SERVICE_ROLE_KEY 不要以 NEXT_PUBLIC_ 开头,否则会被打包到浏览器端代码,造成密钥泄露。

预防方案:在 .env.example 中登记所有环境变量,并在 README 中说明哪些是公开变量、哪些是服务端私有变量。

5.4 文章列表返回后卡片封面图无法显示

问题现象:文章列表渲染正常,但封面图显示裂图,控制台 404。

常见原因:图片路径存储的是相对路径,而页面渲染时未拼接 Supabase Storage 的完整 URL。

解决思路

在服务端查询时直接拼接完整路径:

TS
const imageUrl = `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/images/${coverImagePath}`;

或者在渲染组件中用工具函数统一拼接,避免每个页面各写一遍。

预防方案:在数据库表中存储相对路径,渲染层统一通过图片 URL 工具函数生成带 bucket 的完整 URL。这样迁移 bucket 名称时只需要改一个地方。

5.5 内容区块数据是 JSON,TypeScript 类型不安全

问题现象:后端返回的 content.blocksany 类型,页面组件里访问 block.props.text 时可能拿到 undefined,页面渲染异常。

常见原因:JSON 字段没有运行时校验,也没有 TypeScript 类型守卫。

解决思路

为每个 Block 类型定义类型,并写一个解析函数:

TS
// 文件路径:lib/blocks.ts
export type Block =
| { id: string; type: "heading"; props: { level: number; text: string } }
| { id: string; type: "paragraph"; props: { text: string } }
| { id: string; type: "image"; props: { src: string; alt: string } };
 
export function parseBlocks(value: unknown): Block[] {
if (!Array.isArray(value)) return [];
 
return value.filter((item): item is Block => {
if (!item || typeof item !== "object") return false;
const block = item as Record<string, unknown>;
return typeof block.id === "string" && typeof block.type === "string";
});
}

然后在页面组件中使用:

TSX
const blocks = parseBlocks(post.content?.blocks);

预防方案:编写内容编辑器的导出逻辑时,确保 blocks 数组中的每一项都符合预定义结构;在后端写入前做 JSON Schema 校验会更稳妥。

5.6 Server Action 中无法设置 cookie 导致 Auth 报错

问题现象:在 Server Action 中调用 supabase.auth.signInWithPassword() 时,报错提示 cookie 写入失败。

常见原因@supabase/ssr 要求 cookie 的 set 操作发生在 Server Action 或 Route Handler 中,而不是在 Server Component 渲染期间。

解决思路:登录操作不要在服务端组件的函数里直接调用,而是在 Server Action 或 Route Handler 中执行。代码位置可以参考 app/auth/callback/route.ts,并让前端通过 <form action={login}> 提交。

TS
// 文件路径:app/admin/login/action.ts
"use server";
 
import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
 
export async function login(formData: FormData) {
const supabase = await createClient();
 
const email = formData.get("email") as string;
const password = formData.get("password") as string;
 
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
 
if (error) {
throw new Error(error.message);
}
 
redirect("/admin");
}

预防方案:凡是涉及 Supabase Auth 的 cookie 操作,都通过 Server Action 或 Route Handler 封装,避免在 Server Component 中直接调用认证方法。

5.7 发布后文章不生效,页面还是旧内容

问题现象:在后台改了文章内容,前台页面没有反映最新修改。

常见原因:Next.js 的服务端渲染或静态生成缓存了页面内容,没有触发重新验证。

解决思路

  • 如果使用 Server Component 动态渲染(默认),修改后重新访问通常能拿到新数据。
  • 如果页面配置了 export const dynamic = "force-static"generateStaticParams,需要手动调用 revalidatePathrevalidateTag

在创建/更新文章的 Server Action 里加一行:

TS
revalidatePath("/");
revalidatePath(`/posts/${slug}`);
revalidatePath("/admin");

预防方案:内容更新操作集中放在 Server Action 中,并在每次操作后统一 revalidatePath,保证页面与数据库内容一致。

6. 最佳实践与工程建议

如果你决定在真实项目中使用 NextBlock CMS,或者参考它的架构搭建自己的 CMS,以下工程经验值得借鉴。

6.1 内容模型设计建议

不要把所有内容都塞进一个超级宽表。虽然 jsonb 灵活,但过度使用会带来两个问题:

  1. 查询效率下降。jsonb 字段无法像普通列那样使用常规索引,复杂查询会变慢。
  2. 数据结构失控。没有约束的 JSON 内容会导致不同文章之间风格迥异,渲染组件难以统一。

推荐做法:

  • 基础字段(标题、slug、摘要、发布时间、状态)用独立列存储,方便排序、过滤、索引。
  • 正文内容放在 jsonb 字段中,由 Block 结构统一管理。
  • 需要索引的 JSON 字段,使用 GIN 索引,但不要对整列建立表达式索引,除非查询频率很高。
  • 新增内容类型时,尽量用区块组合,而不是无脑增加表。

6.2 数据库安全边界

RLS 是 Supabase 安全模型的核心,必须理解并正确使用。

第一,所有表默认开启 RLS。只要数据不打算完全公开,就开启 RLS。

第二,写策略要清楚区分「谁可以插入」「谁可以更新」「谁可以删除」。不要简单用一个 auth.role() = 'authenticated' 放开所有登录用户的写权限。至少应该限制为用户只能修改自己的内容:

SQL
create policy "users_update_own_posts"
on public.posts
for update
using (auth.uid() = author_id)
with check (auth.uid() = author_id);

第三,service_role key 不要出现在浏览器端代码里。服务端代码中调用 createClient 时,默认依然使用 anon key 加用户 session。只有在特殊场景(例如后台批量导入、定时任务)才用 service_role key。

6.3 Storage 使用建议

图片上传是 CMS 最常见的功能,使用 Supabase Storage 时有几个实践要点:

文件命名规则。不要把用户上传的文件名直接存为对象名,推荐使用唯一 ID 加扩展名:

TS
const fileExt = file.name.split(".").pop();
const fileName = `${crypto.randomUUID()}.${fileExt}`;
const filePath = `${userId}/${fileName}`;

限制文件类型和大小。Storage 本身有配置项,但建议在上传前校验一次,在服务端再校验一次:

TS
const allowedTypes = ["image/jpeg", "image/png", "image/webp", "image/gif"];
if (!allowedTypes.includes(file.type)) {
throw new Error("不支持的文件类型");
}
if (file.size > 2 * 1024 * 1024) {
throw new Error("文件大小不能超过 2MB");
}

使用图片优化。Next.js 的 next/image 组件可以自动做图片尺寸优化和格式转换。需要把 Supabase Storage 的域名添加到 next.config.jsimages.remotePatterns 中:

JS
// 文件路径:next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "your-project-ref.supabase.co",
pathname: "/storage/v1/object/public/**",
},
],
},
};
 
module.exports = nextConfig;

私有文件的访问。如果图片是付费内容或内部资料,使用公开 bucket 不合适。可以用 Supabase Storage 的 Signed URL,设置有效期后访问:

TS
const { data, error } = await supabase.storage
.from("private-images")
.createSignedUrl(filePath, 60 * 60);

签名 URL 有过期时间,适合临时分享,但注意也会增加每次请求的开销。

6.4 性能优化

CMS 页面通常是读多写少,性能优化的重点在读取路径。

第一,服务端组件中直接查询数据库,不要在前面额外加一层 REST API 包装。Next.js 服务端组件会在服务器上执行查询,数据直接进入渲染流程,减少一次网络请求。

第二,合理使用 Next.js 的缓存能力。对不经常变化的页面使用静态生成或增量静态再生成;对频繁更新的内容页使用动态渲染加 revalidatePath

第三,控制单次查询的数据量。列表页不需要读取完整的 content JSON,只读取标题、摘要、封面图等轻量字段。

TS
const { data } = await supabase
.from("posts")
.select("id, title, slug, excerpt, cover_image, published_at")
.eq("status", "published");

第四,为高频查询字段建立索引。slug 必须有唯一索引,status + published_at 组合使用频率高,可以建复合索引。

SQL
create index if not exists idx_posts_status_published_at
on public.posts (status, published_at desc);

6.5 内容备份与恢复

数据库是 CMS 的核心资产,必须建立备份机制。

Supabase 本身提供连续的备份服务,但本地开发和自托管场景需要手动处理。推荐每天用 pg_dump 导出一次数据库结构加数据:

BASH
pg_dump "postgresql://postgres:postgres@localhost:54322/postgres" > backup_$(date +%F).sql

恢复时:

BASH
psql "postgresql://postgres:postgres@localhost:54322/postgres" < backup_20250101.sql

Storage 中的图片也需要备份。可以写一个定时任务,把 Storage 文件同步到本地或者云存储的另一个 bucket。

6.6 内容审核与发布流程

内容管理不仅仅是技术问题,还要考虑发布流程。

建议在数据模型中加入 status 字段,支持 draftreviewingpublished 三种状态:

  • 编辑创建内容后默认为 draft
  • 内容审核通过后改为 reviewing
  • 最终发布时改为 published

页面上只展示 published 状态的内容,后台可以预览 reviewing 状态。这样即使多个运营人员同时协作,也不会出现「改了一半的内容被用户看到」的尴尬情况。

同时,在 Server Action 中记录「最后修改人」和「修改时间」,为后续的内容审计和回滚提供依据。

7. 总结与学习路线

这篇文章围绕 NextBlock CMS 做了一次比较完整的拆解,从项目定位、核心架构到数据库设计、区块渲染、Server Action 发布,再到 Supabase Storage 接入和常见排错,基本覆盖了一个内容管理系统的核心链路。

读完本文,你应该掌握以下关键点:

  • NextBlock CMS 是用 Next.js 16 + Supabase 组合而成的开源全栈 CMS,核心优势是内容区块化、数据模型代码化、部署轻量化。
  • 数据模型集中在 posts 表,正文以 jsonbblocks 数组存储,通过 type 字段映射到对应的 React 渲染组件。
  • Supabase 在系统中承担数据库、认证、存储三个角色,RLS 是安全体系的关键,任何表开启 RLS 后都必须正确配置 policy。
  • 图片上传走 Supabase Storage,公开访问时用公开 bucket,私有内容用签名 URL。
  • 内容更新后需要通过 revalidatePath 让 Next.js 重新验证缓存,否则页面不会显示最新内容。

接下来你可以继续深入的方向:

  1. 编辑器集成。目前内容 JSON 是手动构造的。可以接入 TipTap、Plate 或亲自实现一个支持 Block 编辑的富文本编辑器,把编辑器的输出直接对接 content.blocks
  2. 权限细化。当前写权限是登录用户即可。可以进一步引入角色表,区分管理员、编辑、投稿者,并在 RLS 策略中做出更细粒度的控制。
  3. SEO 优化。为文章页补充 Open Graph 标签、结构化数据(如 BlogPosting schema)、站点地图生成,利用 Next.js 的 Metadata API 统一管理。
  4. 内容工作流。实现草稿、审核、发布的完整状态机,支持定时发布。
  5. 多语言支持。Post 表增加语言字段和翻译关联,渲染层根据站点语言选择对应内容。
  6. 自托管部署。如果你不想依赖云服务,可以使用 Supabase 自托管版本或直接用 Postgres 加 open-source 的 GoTrue、Storage 服务,把所有服务部署到自己的服务器。

最后提醒一句:CMS 类项目的数据是核心资产。无论使用 NextBlock CMS 还是自己从零搭建,都要保证数据库有备份策略,Storage 中的文件有冗余,环境变量中的密钥严格保密。在这个基础上,再去扩展功能才没有后顾之忧。

如果这篇文章对你有帮助,可以收藏备用;也可以在本地把项目跑起来,动手改一个自定义 Block,体验一下「内容即组件」的开发方式。实践一次之后,你会对 Next.js 全栈开发有更直观的理解。

NextBlock CMS:Next.js 16Supabase构建开源全栈内容管理系统
NextBlock CMS 是基于 Next.js 16(App Router)与 Supabase 构建的开源全栈内容管理系统,支持内容管理、用户认证、文件存储、RBAC 权限控制及 RLS 数据安全策略。本文详述其本地开发环境搭建、Supabase 集成配置、文章发布与图片上传验证、API 暴露、批量导入、性能观察及安全最佳实践,适用于技术博客、文档站等轻量级内容场景。
weixin_30764883
394
NextBlock CMS:基于 Next.js 16Supabase全栈内容管理系统实践
本文详解NextBlock CMS——一个基于Next.js 16Supabase构建的全栈内容管理系统。涵盖环境准备、部署启动、核心功能验证(内容管理、用户认证、文件存储、API接口、响应式渲染)、性能优化、常见问题排查及最佳实践。重点突出其技术统一性、开发者友好性及在中小型内容项目中的落地路径,强调Supabase数据库、Auth、Storage与Next.js App Router的协同机制。
weixin_34258838
401
Next.js 16Supabase 构建全栈 CMS 的完整指南
本文详解如何基于 Next.js 16(支持 React Server Components 和 Server Actions)与 Supabase 构建开源可控的全栈 CMS。涵盖架构设计、Supabase 数据库建模(含 RLS 行级安全策略)、Auth 认证集成、Storage 文件管理、Server Actions 实现增删改、RSC 前台渲染、ISR 缓存策略及生产部署安全实践,适用于博客、知识库等场景。
weixin_34129696
354