🐷

「1つのファイル」だけでバックエンドを構築【PocketBase】

に公開

PocketBaseとは

PocketBaseは、Firebase や Supabase のような BaaS(Backend as a Service)を、オープンソースで簡単に構築できるツールです。

公式サイトには Open Source backend in 1 file とありますが、「ソースコードが1ファイル」という意味ではなく、「1つの実行可能ファイル(バイナリ)だけでバックエンドが完結する」という意味です。
この 1 ファイルの中に、サーバーAPIDBAuth管理画面 がすべて含まれており、外部サービスを組み合わせなくてもバックエンド一式を立ち上げられます。

詳しくは公式サイトも参照してください。
https://pocketbase.io/

構築

PocketBase の構築方法はいくつかありますが、この記事では Docker を使った方法を紹介します。
基本的な考え方は、公式ドキュメントに掲載されている Dockerfile をベースに、自分の環境向けに調整していく形です。

公式の手順はこちらです。
https://pocketbase.io/docs/going-to-production/#using-docker

Dockerfile

/docker/frontend/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"]
/docker/pocketBase/Dockerfile
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

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

app/src/pocketBaseClient.ts
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)を確認できるようにしています。

app/src/App.ts
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 リポジトリにすべて置いてあるのでそちらも参照してみてください。
https://github.com/roll1226/study-pocket-base

起動

bash
docker compose up -d

スーパーアカウント作成

まずは、PocketBase の管理画面にログインするための「スーパーアカウント(管理者アカウント)」を作成します。

bash
docker compose exec pocketbase /pb/pocketbase superuser upsert email@example.com password123456

コマンドが成功したら、ブラウザで http://localhost:8080/_/ にアクセスし、先ほど作成したアカウントでログインします。
alt text

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

コレクションの作成

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

Firebase の Security Rules のように、アクセス制御ルールを細かく設定することもできます。
今回のサンプルでは、「実行ユーザーが操作できるのは、自分に紐づいたデータだけ」という形になるよう、リレーションで紐づけたユーザーとリクエストしているユーザーが一致しているかをチェックしています。

他にもさまざまな条件やルールを設定できるので、詳細は公式ドキュメントを確認してみてください。
公式ドキュメント: API rules and filters
alt text

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

まとめ

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

Discussion