跳转到内容

Next.js 集成

PocketBase 可以与 Next.js 完美集成,支持客户端和服务端渲染。

Terminal window
npm install pocketbase
# 或
yarn add pocketbase
# 或
pnpm add pocketbase
your-nextjs-app/
├── src/
│ ├── app/
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ └── api/
│ ├── lib/
│ │ └── pocketbase.ts
│ └── middleware.ts
├── .env.local
└── next.config.js
src/lib/pocketbase.ts
import PocketBase from "pocketbase";
export const pb = new PocketBase(
process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090",
);
// 类型定义
export interface User {
id: string;
collectionId: string;
collectionName: string;
username?: string;
email: string;
emailVisibility: boolean;
verified: boolean;
name?: string;
role?: "user" | "author" | "admin";
avatar?: string;
created: string;
updated: string;
}
export interface Post {
id: string;
collectionId: string;
collectionName: string;
title: string;
slug: string;
content: string;
excerpt?: string;
cover?: string;
status: "draft" | "published" | "archived";
author: string;
featured: boolean;
views: number;
publishedAt?: string;
created: string;
updated: string;
}
// 扩展 PocketBase 类型
declare module "pocketbase" {
interface CollectionRecords {
users: User;
posts: Post;
}
}
.env.local
NEXT_PUBLIC_POCKETBASE_URL=http://127.0.0.1:8090
src/app/page.tsx
import { pb } from "@/lib/pocketbase";
import Link from "next/link";
async function getPosts() {
try {
const result = await pb.collection("posts").getList(1, 10, {
filter: 'status = "published"',
sort: "-publishedAt",
expand: "author",
});
return result;
} catch (error) {
console.error("Failed to fetch posts:", error);
return { items: [], totalItems: 0 };
}
}
export default async function HomePage() {
const posts = await getPosts();
return (
<div>
<h1>Latest Posts</h1>
{posts.items.map((post) => (
<article key={post.id}>
<Link href={`/posts/${post.slug}`}>
<h2>{post.title}</h2>
</Link>
<p>{post.excerpt}</p>
{post.expand?.author && (
<p>By {post.expand.author.name || post.expand.author.email}</p>
)}
</article>
))}
</div>
);
}
src/app/posts/[slug]/page.tsx
import { pb } from "@/lib/pocketbase";
import { notFound } from "next/navigation";
async function getPost(slug: string) {
try {
const post = await pb.collection("posts").getFirstListItem(
`slug = '${slug}' && status = 'published'`,
{
expand: "author",
},
);
return post;
} catch (error) {
return null;
}
}
export async function generateMetadata({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug);
if (!post) {
return {
title: "Post Not Found",
};
}
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: post.cover ? [pb.files.getUrl(post, post.cover)] : [],
},
};
}
export default async function PostPage({
params,
}: {
params: { slug: string };
}) {
const post = await getPost(params.slug);
if (!post) {
notFound();
}
return (
<article>
<h1>{post.title}</h1>
{post.cover && (
<img src={pb.files.getUrl(post, post.cover)} alt={post.title} />
)}
<div dangerouslySetInnerHTML={{ __html: post.content }} />
{post.expand?.author && (
<p>Author: {post.expand.author.name}</p>
)}
</article>
);
}
src/app/posts/[slug]/page.tsx
export async function generateStaticParams() {
try {
const { items } = await pb.collection("posts").getList(1, 100, {
filter: 'status = "published"',
fields: "slug",
});
return items.map((post) => ({
slug: post.slug,
}));
} catch (error) {
return [];
}
}
src/components/AuthProvider.tsx
"use client";
import { createContext, useContext, useEffect, useState, ReactNode } from "react";
import { pb, User } from "@/lib/pocketbase";
interface AuthContextType {
user: User | null;
token: string;
isAuthenticated: boolean;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
register: (email: string, password: string) => Promise<void>;
logout: () => void;
refresh: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string>("");
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// 恢复认证状态
if (typeof window !== "undefined") {
setUser(pb.authStore.model as User | null);
setToken(pb.authStore.token);
}
setIsLoading(false);
// 监听认证状态变化
const unsubscribe = pb.authStore.onChange(() => {
setUser(pb.authStore.model as User | null);
setToken(pb.authStore.token);
});
return unsubscribe;
}, []);
const login = async (email: string, password: string) => {
const authData = await pb
.collection("users")
.authWithPassword(email, password);
setUser(authData.record as User);
setToken(authData.token);
};
const register = async (email: string, password: string) => {
const record = await pb.collection("users").create({
email,
password,
passwordConfirm: password,
});
return record;
};
const logout = () => {
pb.authStore.clear();
setUser(null);
setToken("");
};
const refresh = async () => {
try {
const authData = await pb.collection("users").authRefresh();
setUser(authData.record as User);
setToken(authData.token);
} catch (error) {
logout();
throw error;
}
};
return (
<AuthContext.Provider
value={{
user,
token,
isAuthenticated: !!token,
isLoading,
login,
register,
logout,
refresh,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within AuthProvider");
}
return context;
}
src/components/LoginForm.tsx
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "./AuthProvider";
export function LoginForm() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const router = useRouter();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
try {
await login(email, password);
router.push("/dashboard");
} catch (err: any) {
setError(err.message || "Login failed");
} finally {
setLoading(false);
}
}
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.value)}
required
/>
</div>
{error && <p className="error">{error}</p>}
<button type="submit" disabled={loading}>
{loading ? "Logging in..." : "Login"}
</button>
</form>
);
}
src/app/actions/posts.ts
"use server";
import { pb } from "@/lib/pocketbase";
import { revalidatePath } from "next/cache";
import { cookies } from "next/headers";
export async function createPost(formData: FormData) {
const cookieStore = cookies();
const token = cookieStore.get("pb_auth");
if (token) {
pb.authStore.save(token.value, "");
}
try {
const post = await pb.collection("posts").create({
title: formData.get("title"),
content: formData.get("content"),
slug: formData.get("slug"),
status: "draft",
});
revalidatePath("/posts");
revalidatePath("/dashboard");
return { success: true, post };
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function updatePost(id: string, formData: FormData) {
const cookieStore = cookies();
const token = cookieStore.get("pb_auth");
if (token) {
pb.authStore.save(token.value, "");
}
try {
const post = await pb.collection("posts").update(id, {
title: formData.get("title"),
content: formData.get("content"),
status: formData.get("status"),
});
revalidatePath("/posts/[slug]", "page");
return { success: true, post };
} catch (error: any) {
return { success: false, error: error.message };
}
}
export async function deletePost(id: string) {
const cookieStore = cookies();
const token = cookieStore.get("pb_auth");
if (token) {
pb.authStore.save(token.value, "");
}
try {
await pb.collection("posts").delete(id);
revalidatePath("/posts");
revalidatePath("/dashboard");
return { success: true };
} catch (error: any) {
return { success: false, error: error.message };
}
}
src/app/dashboard/new/page.tsx
import { createPost } from "@/app/actions/posts";
export default function NewPostPage() {
return (
<form action={async (formData) => {
"use server";
const result = await createPost(formData);
if (!result.success) {
console.error(result.error);
}
}}>
<input name="title" placeholder="Title" required />
<input name="slug" placeholder="Slug" required />
<textarea name="content" placeholder="Content" required />
<button type="submit">Create Post</button>
</form>
);
}
src/middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const protectedPaths = ["/dashboard", "/posts/new"];
const authPaths = ["/login", "/register"];
export function middleware(request: NextRequest) {
const token = request.cookies.get("pb_auth")?.value;
const { pathname } = request.nextUrl;
// 检查是否需要认证
const isProtectedPath = protectedPaths.some((path) =>
pathname.startsWith(path),
);
const isAuthPath = authPaths.some((path) => pathname.startsWith(path));
// 未认证用户访问受保护路径
if (isProtectedPath && !token) {
const url = request.nextUrl.clone();
url.pathname = "/login";
return NextResponse.redirect(url);
}
// 已认证用户访问登录页
if (isAuthPath && token) {
const url = request.nextUrl.clone();
url.pathname = "/dashboard";
return NextResponse.redirect(url);
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};
src/app/api/auth/login/route.ts
import { NextRequest, NextResponse } from "next/server";
import { pb } from "@/lib/pocketbase";
export async function POST(request: NextRequest) {
try {
const { email, password } = await request.json();
const authData = await pb
.collection("users")
.authWithPassword(email, password);
const response = NextResponse.json({
user: authData.record,
token: authData.token,
});
// 设置 cookie
response.cookies.set("pb_auth", authData.token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7, // 7 天
path: "/",
});
return response;
} catch (error: any) {
return NextResponse.json(
{ error: error.message || "Login failed" },
{ status: 401 },
);
}
}
src/app/api/auth/logout/route.ts
import { NextResponse } from "next/server";
export async function POST() {
const response = NextResponse.json({ success: true });
// 清除 cookie
response.cookies.delete("pb_auth", {
path: "/",
});
return response;
}
src/app/api/upload/route.ts
import { NextRequest, NextResponse } from "next/server";
import { pb } from "@/lib/pocketbase";
import { cookies } from "next/headers";
export async function POST(request: NextRequest) {
const cookieStore = cookies();
const token = cookieStore.get("pb_auth")?.value;
if (!token) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
pb.authStore.save(token, "");
try {
const formData = await request.formData();
const file = formData.get("file") as File;
const title = formData.get("title") as string;
if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
const record = await pb.collection("posts").create({
title,
cover: file,
});
return NextResponse.json({ record });
} catch (error: any) {
return NextResponse.json(
{ error: error.message || "Upload failed" },
{ status: 500 },
);
}
}
src/components/FileUpload.tsx
"use client";
import { useState, useRef } from "react";
import { pb } from "@/lib/pocketbase";
export function FileUpload() {
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const fileInput = useRef<HTMLInputElement>(null);
async function uploadFile(file: File) {
setUploading(true);
setProgress(0);
return new Promise((resolve, reject) => {
const formData = new FormData();
formData.append("file", file);
formData.append("title", "Uploaded file");
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) {
setProgress(Math.round((e.loaded / e.total) * 100));
}
});
xhr.addEventListener("load", () => {
if (xhr.status === 200) {
resolve(JSON.parse(xhr.responseText));
} else {
reject(new Error(xhr.statusText));
}
setUploading(false);
});
xhr.open("POST", "/api/upload");
xhr.setRequestHeader("Authorization", `Bearer ${pb.authStore.token}`);
xhr.send(formData);
});
}
return (
<div>
<input ref={fileInput} type="file" disabled={uploading} />
<p>{uploading ? `Uploading: ${progress}%` : "Select file"}</p>
</div>
);
}
src/app/posts/page.tsx
import { pb } from "@/lib/pocketbase";
export const revalidate = 60; // 每 60 秒重新生成
async function getPosts() {
const result = await pb.collection("posts").getList(1, 20, {
filter: 'status = "published"',
sort: "-publishedAt",
});
return result;
}
export default async function PostsPage() {
const posts = await getPosts();
return (
<div>
<h1>Posts</h1>
{posts.items.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
))}
</div>
);
}
  1. 使用环境变量:将 PocketBase URL 配置在环境变量中
  2. 类型安全:使用 TypeScript 定义集合类型
  3. 服务端优先:尽可能在服务端获取数据
  4. Cookie 存储:使用 httpOnly cookie 存储 token
  5. 错误处理:妥善处理 API 错误
  6. 性能优化:使用 ISR 和静态生成
  7. 安全配置:使用中间件保护敏感路由

使用 cookie 传递 token,在服务端读取:

const token = request.cookies.get("pb_auth")?.value;
if (token) {
pb.authStore.save(token, "");
}

在客户端组件中使用订阅:

"use client";
import { useEffect, useState } from "react";
import { pb } from "@/lib/pocketbase";
export function RealtimePosts() {
const [posts, setPosts] = useState([]);
useEffect(() => {
// 订阅
const subscription = pb.collection("posts").subscribe("*", (e) => {
if (e.action === "create") {
setPosts((prev: any[]) => [e.record, ...prev]);
}
});
return () => {
subscription.then((sub) => sub?.unsubscribe());
};
}, []);
return <div>{/* ... */}</div>;
}
  1. 确保 PocketBase 服务器已部署
  2. 配置环境变量 NEXT_PUBLIC_POCKETBASE_URL
  3. 正常部署即可

Q: SSR 渲染时如何获取用户信息?

Section titled “Q: SSR 渲染时如何获取用户信息?”
export async function getServerSideProps(context: any) {
const token = context.req.cookies.pb_auth;
if (token) {
pb.authStore.save(token, "");
const user = await pb.collection("users").authRefresh();
return {
props: { user: user.record },
};
}
return {
props: { user: null },
};
}