「1つのファイル」だけでバックエンドを構築【PocketBase】
PocketBaseとは
PocketBaseは、Firebase や Supabase のような BaaS(Backend as a Service)を、オープンソースで簡単に構築できるツールです。
公式サイトには Open Source backend in 1 file とありますが、「ソースコードが1ファイル」という意味ではなく、「1つの実行可能ファイル(バイナリ)だけでバックエンドが完結する」という意味です。
この 1 ファイルの中に、サーバー・API・DB・Auth・管理画面 がすべて含まれており、外部サービスを組み合わせなくてもバックエンド一式を立ち上げられます。
詳しくは公式サイトも参照してください。
構築
PocketBase の構築方法はいくつかありますが、この記事では Docker を使った方法を紹介します。
基本的な考え方は、公式ドキュメントに掲載されている Dockerfile をベースに、自分の環境向けに調整していく形です。
公式の手順はこちらです。
Dockerfile
FROM node:23.1.0
WORKDIR /app
COPY package.json /app
COPY package-lock.json /app
RUN npm install
COPY . /app
EXPOSE 3000
CMD ["npm", "dev"]
FROM alpine:latest
ARG PB_VERSION=0.35.0
RUN apk add --no-cache \
unzip \
ca-certificates
# download and unzip PocketBase
ADD https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_amd64.zip /tmp/pb.zip
RUN unzip /tmp/pb.zip -d /pb/
# uncomment to copy the local pb_migrations dir into the image
# COPY ./pb_migrations /pb/pb_migrations
# uncomment to copy the local pb_hooks dir into the image
# COPY ./pb_hooks /pb/pb_hooks
EXPOSE 8080
# start PocketBase
CMD ["/pb/pocketbase", "serve", "--http=0.0.0.0:8080"]
docker-compose.yml
version: '3.9'
services:
frontend:
image: node:23.1.0
working_dir: /app
command: sh -c "npm install && npm run dev -- --host 0.0.0.0 --port 3000"
ports:
- "3000:3000"
environment:
- VITE_POCKETBASE_URL=${VITE_POCKETBASE_URL}
volumes:
- ./app:/app
- frontend_node_modules:/app/node_modules
depends_on:
pocketbase:
condition: service_healthy
pocketbase:
build:
context: ./docker/pocketBase
ports:
- "8080:8080"
volumes:
- pocketbase_data:/pb/pb_data
healthcheck:
test: ["CMD", "wget", "--spider", "--quiet", "http://127.0.0.1:8080/api/health"]
interval: 5s
timeout: 5s
retries: 5
volumes:
frontend_node_modules:
pocketbase_data:
Client
import PocketBase from "pocketbase";
const POCKETBASE_URL = import.meta.env.VITE_POCKETBASE_URL;
const pb = new PocketBase(POCKETBASE_URL);
// Avoid auto-cancelling duplicate requests while components rerender.
pb.autoCancellation(false);
export default pb;
App.tsx
実装としてはあくまで学習用・記事用のシンプルなサンプルですが、PocketBase を使ったフロントエンドからの一連の流れ(認証・TODO の CRUD)を確認できるようにしています。
import type { AuthModel, RecordModel } from "pocketbase";
import { type FormEvent, useEffect, useState } from "react";
import "./App.css";
import pb from "./pocketbaseClient";
type TodoRecord = RecordModel & {
title: string;
user: string;
};
const parseError = (error: unknown, fallback: string) =>
error instanceof Error ? error.message : fallback;
function App() {
const [currentUser, setCurrentUser] = useState<AuthModel | null>(
pb.authStore.model
);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [passwordConfirm, setPasswordConfirm] = useState("");
const [isRegisterMode, setIsRegisterMode] = useState(false);
const [authLoading, setAuthLoading] = useState(false);
const [authError, setAuthError] = useState("");
const [todos, setTodos] = useState<TodoRecord[]>([]);
const [todosLoading, setTodosLoading] = useState(false);
const [todoError, setTodoError] = useState("");
const [newTitle, setNewTitle] = useState("");
const [isCreatingTodo, setIsCreatingTodo] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [editingTitle, setEditingTitle] = useState("");
const [pendingTodoId, setPendingTodoId] = useState<string | null>(null);
useEffect(() => {
const remove = pb.authStore.onChange((_, model) => {
setCurrentUser(model);
});
return () => {
remove();
};
}, []);
useEffect(() => {
let isActive = true;
if (!currentUser) {
setTodos([]);
return () => {
isActive = false;
};
}
setTodosLoading(true);
setTodoError("");
pb.collection("todos")
.getFullList<TodoRecord>(200, {
filter: `user="${currentUser.id}"`,
sort: "-created",
})
.then((records) => {
if (!isActive) {
return;
}
setTodos(records);
})
.catch((error) => {
if (!isActive) {
return;
}
setTodoError(parseError(error, "TODOの取得に失敗しました。"));
})
.finally(() => {
if (!isActive) {
return;
}
setTodosLoading(false);
});
return () => {
isActive = false;
};
}, [currentUser]);
const handleLogin = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setAuthError("");
setAuthLoading(true);
try {
await pb.collection("users").authWithPassword(email, password);
setEmail("");
setPassword("");
setPasswordConfirm("");
} catch (error) {
setAuthError(
parseError(
error,
"ログインに失敗しました。メールアドレスとパスワードをご確認ください。"
)
);
} finally {
setAuthLoading(false);
}
};
const handleRegister = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const trimmedEmail = email.trim();
const trimmedPassword = password.trim();
const trimmedConfirm = passwordConfirm.trim();
if (trimmedEmail === "") {
setAuthError("メールアドレスを入力してください。");
return;
}
if (trimmedPassword === "" || trimmedConfirm === "") {
setAuthError("パスワードを入力してください。");
return;
}
if (trimmedPassword !== trimmedConfirm) {
setAuthError("パスワードが一致しません。");
return;
}
setAuthError("");
setAuthLoading(true);
try {
await pb.collection("users").create({
email: trimmedEmail,
password: trimmedPassword,
passwordConfirm: trimmedConfirm,
});
await pb
.collection("users")
.authWithPassword(trimmedEmail, trimmedPassword);
setEmail("");
setPassword("");
setPasswordConfirm("");
} catch (error) {
setAuthError(
parseError(error, "アカウント作成に失敗しました。再度お試しください。")
);
} finally {
setAuthLoading(false);
}
};
const handleLogout = () => {
pb.authStore.clear();
setTodos([]);
setAuthError("");
setTodoError("");
setEmail("");
setPassword("");
setPasswordConfirm("");
setNewTitle("");
setEditingId(null);
setEditingTitle("");
setPendingTodoId(null);
setIsRegisterMode(false);
};
const toggleAuthMode = () => {
if (authLoading) {
return;
}
setIsRegisterMode((previous) => !previous);
setAuthError("");
setEmail("");
setPassword("");
setPasswordConfirm("");
};
const handleCreateTodo = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!currentUser) {
return;
}
const trimmedTitle = newTitle.trim();
if (!trimmedTitle) {
return;
}
setIsCreatingTodo(true);
setTodoError("");
try {
const record = await pb.collection("todos").create<TodoRecord>({
title: trimmedTitle,
user: currentUser.id,
});
setTodos((previous) => [record, ...previous]);
setNewTitle("");
} catch (error) {
setTodoError(parseError(error, "TODOの追加に失敗しました。"));
} finally {
setIsCreatingTodo(false);
}
};
const startEdit = (todo: TodoRecord) => {
setEditingId(todo.id);
setEditingTitle(todo.title);
setTodoError("");
};
const cancelEdit = () => {
setEditingId(null);
setEditingTitle("");
};
const handleSaveEdit = async () => {
if (!editingId) {
return;
}
const trimmed = editingTitle.trim();
if (!trimmed) {
return;
}
setPendingTodoId(editingId);
setTodoError("");
try {
const updated = await pb
.collection("todos")
.update<TodoRecord>(editingId, { title: trimmed });
setTodos((previous) =>
previous.map((todo) => (todo.id === updated.id ? updated : todo))
);
setEditingId(null);
setEditingTitle("");
} catch (error) {
setTodoError(parseError(error, "TODOの更新に失敗しました。"));
} finally {
setPendingTodoId(null);
}
};
const handleDelete = async (id: string) => {
setPendingTodoId(id);
setTodoError("");
try {
await pb.collection("todos").delete(id);
setTodos((previous) => previous.filter((todo) => todo.id !== id));
if (editingId === id) {
setEditingId(null);
setEditingTitle("");
}
} catch (error) {
setTodoError(parseError(error, "TODOの削除に失敗しました。"));
} finally {
setPendingTodoId(null);
}
};
return (
<div className="app-root">
{!currentUser ? (
<div className="app-shell auth-shell">
<h1 className="app-title">
{isRegisterMode ? "新規アカウント作成" : "PocketBaseにログイン"}
</h1>
<p className="app-subtitle">
{isRegisterMode
? "メールアドレスとパスワードを入力してアカウントを作成してください。"
: "事前に作成済みのPocketBaseユーザーでログインしてください。"}
</p>
{authError && <div className="error-message">{authError}</div>}
<form
className="auth-form"
onSubmit={isRegisterMode ? handleRegister : handleLogin}
>
<label className="form-field">
<span>メールアドレス</span>
<input
className="input-field"
type="email"
autoComplete="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="example@example.com"
required
/>
</label>
<label className="form-field">
<span>パスワード</span>
<input
className="input-field"
type="password"
autoComplete={
isRegisterMode ? "new-password" : "current-password"
}
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="パスワード"
required
/>
</label>
{isRegisterMode && (
<label className="form-field">
<span>パスワード(確認)</span>
<input
className="input-field"
type="password"
autoComplete="new-password"
value={passwordConfirm}
onChange={(event) => setPasswordConfirm(event.target.value)}
placeholder="パスワードを再入力"
required
/>
</label>
)}
<button
className="primary-button"
type="submit"
disabled={
authLoading ||
(isRegisterMode && password.trim() !== passwordConfirm.trim())
}
>
{authLoading
? isRegisterMode
? "作成中…"
: "認証中…"
: isRegisterMode
? "アカウント作成"
: "ログイン"}
</button>
</form>
<div className="auth-toggle">
<span>
{isRegisterMode
? "既にアカウントをお持ちの場合はこちら"
: "アカウントをお持ちでない場合はこちら"}
</span>
<button
className="link-button"
type="button"
onClick={toggleAuthMode}
disabled={authLoading}
>
{isRegisterMode ? "ログイン画面へ" : "新規アカウント作成"}
</button>
</div>
</div>
) : (
<div className="app-shell todo-shell">
<header className="todo-header">
<div>
<h1 className="app-title">TODOボード</h1>
<p className="app-subtitle">
ログイン中: {currentUser.email ?? "ユーザー"}
</p>
</div>
<button
className="secondary-button"
type="button"
onClick={handleLogout}
>
ログアウト
</button>
</header>
<section className="todo-section">
<h2 className="section-title">TODOの追加</h2>
<form className="todo-form" onSubmit={handleCreateTodo}>
<input
className="input-field"
type="text"
value={newTitle}
onChange={(event) => setNewTitle(event.target.value)}
placeholder="タイトルを入力"
disabled={isCreatingTodo}
/>
<button
className="primary-button"
type="submit"
disabled={isCreatingTodo || !newTitle.trim()}
>
{isCreatingTodo ? "追加中…" : "追加"}
</button>
</form>
</section>
{todoError && <div className="error-message">{todoError}</div>}
<section className="todo-section">
<h2 className="section-title">あなたのTODO</h2>
{todosLoading ? (
<p className="info-message">読み込み中…</p>
) : todos.length === 0 ? (
<p className="todo-empty">まだTODOは登録されていません。</p>
) : (
<ul className="todo-items">
{todos.map((todo) => (
<li key={todo.id} className="todo-item">
{editingId === todo.id ? (
<input
className="input-field todo-input"
type="text"
value={editingTitle}
onChange={(event) =>
setEditingTitle(event.target.value)
}
disabled={pendingTodoId === todo.id}
/>
) : (
<span className="todo-title">{todo.title}</span>
)}
<div className="todo-actions">
{editingId === todo.id ? (
<>
<button
className="primary-button"
type="button"
onClick={handleSaveEdit}
disabled={
pendingTodoId === todo.id || !editingTitle.trim()
}
>
保存
</button>
<button
className="secondary-button"
type="button"
onClick={cancelEdit}
disabled={pendingTodoId === todo.id}
>
キャンセル
</button>
</>
) : (
<>
<button
className="secondary-button"
type="button"
onClick={() => startEdit(todo)}
disabled={pendingTodoId === todo.id}
>
編集
</button>
<button
className="danger-button"
type="button"
onClick={() => handleDelete(todo.id)}
disabled={pendingTodoId === todo.id}
>
削除
</button>
</>
)}
</div>
</li>
))}
</ul>
)}
</section>
</div>
)}
</div>
);
}
export default App;
全コードが気になる方は、GitHub リポジトリにすべて置いてあるのでそちらも参照してみてください。
起動
docker compose up -d
スーパーアカウント作成
まずは、PocketBase の管理画面にログインするための「スーパーアカウント(管理者アカウント)」を作成します。
docker compose exec pocketbase /pb/pocketbase superuser upsert email@example.com password123456
コマンドが成功したら、ブラウザで http://localhost:8080/_/ にアクセスし、先ほど作成したアカウントでログインします。

ログインに成功すると、次のような管理画面が表示されます。

コレクションの作成
PocketBase では、データベースのテーブルに相当するものを「コレクション」と呼びます。
Firebase を触ったことがある方であれば、ほぼ同じ感覚で直感的に操作できると思います。

Firebase の Security Rules のように、アクセス制御ルールを細かく設定することもできます。
今回のサンプルでは、「実行ユーザーが操作できるのは、自分に紐づいたデータだけ」という形になるよう、リレーションで紐づけたユーザーとリクエストしているユーザーが一致しているかをチェックしています。
他にもさまざまな条件やルールを設定できるので、詳細は公式ドキュメントを確認してみてください。
公式ドキュメント: API rules and filters

最後に、実際に動作させた様子がこちらです。

まとめ
PocketBaseの簡単な部分しか触っていませんが手軽に環境構築が出来ました
Authや画像管理などまだまだ触っていない機能にも今後触って行こうと思います
Discussion