Next.js 集成
PocketBase 可以与 Next.js 完美集成,支持客户端和服务端渲染。
npm install pocketbase# 或yarn add pocketbase# 或pnpm add pocketbaseyour-nextjs-app/├── src/│ ├── app/│ │ ├── layout.tsx│ │ ├── page.tsx│ │ └── api/│ ├── lib/│ │ └── pocketbase.ts│ └── middleware.ts├── .env.local└── next.config.jsPocketBase 客户端
Section titled “PocketBase 客户端”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; }}NEXT_PUBLIC_POCKETBASE_URL=http://127.0.0.1:8090App Router 集成
Section titled “App Router 集成”服务端获取数据
Section titled “服务端获取数据”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> );}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> );}生成静态参数
Section titled “生成静态参数”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 []; }}"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;}"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> );}Server Actions
Section titled “Server Actions”"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 }; }}使用 Server Actions
Section titled “使用 Server Actions”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> );}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).*)"],};API 路由
Section titled “API 路由”认证 API
Section titled “认证 API”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 }, ); }}登出 API
Section titled “登出 API”import { NextResponse } from "next/server";
export async function POST() { const response = NextResponse.json({ success: true });
// 清除 cookie response.cookies.delete("pb_auth", { path: "/", });
return response;}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 }, ); }}"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> );}ISR (增量静态再生)
Section titled “ISR (增量静态再生)”配置 ISR
Section titled “配置 ISR”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> );}- 使用环境变量:将 PocketBase URL 配置在环境变量中
- 类型安全:使用 TypeScript 定义集合类型
- 服务端优先:尽可能在服务端获取数据
- Cookie 存储:使用 httpOnly cookie 存储 token
- 错误处理:妥善处理 API 错误
- 性能优化:使用 ISR 和静态生成
- 安全配置:使用中间件保护敏感路由
Q: 如何处理 SSR 中的认证?
Section titled “Q: 如何处理 SSR 中的认证?”使用 cookie 传递 token,在服务端读取:
const token = request.cookies.get("pb_auth")?.value;if (token) { pb.authStore.save(token, "");}Q: 如何实现实时功能?
Section titled “Q: 如何实现实时功能?”在客户端组件中使用订阅:
"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>;}Q: 如何部署到 Vercel?
Section titled “Q: 如何部署到 Vercel?”- 确保 PocketBase 服务器已部署
- 配置环境变量
NEXT_PUBLIC_POCKETBASE_URL - 正常部署即可
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 }, };}