Utilizziamo i cookie per migliorare la tua esperienza sul sito
CodeWorlds
Torna alle collezioni
Guide48 min read

PocketBase

PocketBase is a backend in a single file - SQLite, auth, realtime, and admin UI. Complete guide for developers.

PocketBase - Backend w Jednym Pliku

Czym jest PocketBase?

PocketBase to open-source backend aplikacji skompresowany do jednego wykonywalnego pliku o wielkości około 15MB. Zawiera w sobie wszystko, czego potrzebujesz do uruchomienia backendu: bazę danych SQLite, system autentykacji, realtime subscriptions, file storage oraz wbudowany admin panel. Jest napisany w Go, co zapewnia wysoką wydajność i łatwość deploymentu.

PocketBase został stworzony przez Gani Georgieva jako alternatywa dla rozbudowanych rozwiązań BaaS (Backend as a Service) jak Firebase czy Supabase. Jego filozofia opiera się na prostocie - zamiast konfigurować dziesiątki serwisów, uruchamiasz jeden plik i masz działający backend w sekundy.

Dlaczego PocketBase?

Kluczowe zalety

  1. Jeden plik wykonywalny - Cały backend w jednym pliku ~15MB
  2. Zero konfiguracji - Uruchom i działa od razu
  3. SQLite - Sprawdzona, szybka baza danych dla małych/średnich projektów
  4. Wbudowany Admin UI - Zarządzaj danymi bez dodatkowych narzędzi
  5. Realtime - Subskrypcje WebSocket dla live updates
  6. Self-hosted - Pełna kontrola nad danymi
  7. Darmowy i open-source - MIT License

PocketBase vs inne rozwiązania BaaS

CechaPocketBaseFirebaseSupabaseAppwrite
Self-hostedTakNieTakTak
CenaDarmowyFreemiumFreemiumDarmowy
Baza danychSQLiteFirestorePostgreSQLMariaDB
Rozmiar deploymentu~15MBCloud~2GB~1GB
KonfiguracjaZeroŚredniaDużaŚrednia
RealtimeTakTakTakTak
Admin PanelTakTakTakTak
SDK JSTakTakTakTak
Extendable w GoTakNieNieNie
OAuth providersTakTakTakTak
File StorageTakTakTakTak

Kiedy wybrać PocketBase?

Idealne zastosowania:

  • MVP i prototypy
  • Projekty side-project
  • Małe i średnie aplikacje (do ~100K użytkowników)
  • Aplikacje wymagające self-hostingu
  • Projekty z ograniczonym budżetem
  • Szybkie proof-of-concept

Nie zalecane dla:

  • Aplikacji enterprise z milionami użytkowników
  • Projektów wymagających zaawansowanych zapytań SQL
  • Aplikacji z bardzo złożonymi relacjami danych
  • Systemów wymagających horizontal scaling

Instalacja i szybki start

Download i uruchomienie

Code
Bash
# Linux AMD64
wget https://github.com/pocketbase/pocketbase/releases/download/v0.22.0/pocketbase_0.22.0_linux_amd64.zip
unzip pocketbase_0.22.0_linux_amd64.zip

# macOS ARM64 (Apple Silicon)
wget https://github.com/pocketbase/pocketbase/releases/download/v0.22.0/pocketbase_0.22.0_darwin_arm64.zip
unzip pocketbase_0.22.0_darwin_arm64.zip

# macOS AMD64 (Intel)
wget https://github.com/pocketbase/pocketbase/releases/download/v0.22.0/pocketbase_0.22.0_darwin_amd64.zip
unzip pocketbase_0.22.0_darwin_amd64.zip

# Windows
# Pobierz pocketbase_0.22.0_windows_amd64.zip z GitHub Releases

# Uruchomienie serwera
./pocketbase serve

# Z custom portem
./pocketbase serve --http="127.0.0.1:8080"

# Z publicznym dostępem
./pocketbase serve --http="0.0.0.0:8090"

Struktura katalogów

Po uruchomieniu PocketBase tworzy katalog pb_data/:

Code
TEXT
my-app/
├── pocketbase           # Plik wykonywalny
├── pb_data/
│   ├── data.db         # Baza danych SQLite
│   ├── storage/        # Przechowywane pliki
│   └── backups/        # Automatyczne backupy
└── pb_migrations/       # Opcjonalne migracje

Pierwsze uruchomienie Admin UI

Po starcie serwera przejdź do:

Code
TEXT
http://127.0.0.1:8090/_/

Przy pierwszym uruchomieniu zostaniesz poproszony o utworzenie konta administratora. Podaj email i hasło - będą to Twoje dane logowania do panelu administracyjnego.

Admin Panel

Tworzenie kolekcji (tabel)

Admin Panel pozwala na wizualne tworzenie i zarządzanie kolekcjami:

  1. Przejdź do Settings > Collections
  2. Kliknij "New collection"
  3. Wybierz typ kolekcji:
    • Base - Standardowa kolekcja danych
    • Auth - Kolekcja użytkowników z autentykacją
    • View - Widok (read-only) oparty na innych kolekcjach

Definiowanie schematu

Dostępne typy pól:

TypOpisPrzykład
TextKrótki tekstnazwa, tytuł
EditorRich text (HTML)treść artykułu
NumberLiczba (int/float)cena, ilość
BoolPrawda/fałszopublikowany, aktywny
EmailWalidowany emailemail użytkownika
URLWalidowany URLlink do strony
DateData i czasdata_utworzenia
SelectJeden z listystatus, kategoria
FilePrzesłany plikavatar, załącznik
RelationRelacja do innej kolekcjiautor -> users
JSONDowolny obiekt JSONmetadata, settings

Przykładowa struktura dla bloga

Code
TEXT
Kolekcja: posts
├── title (Text, required)
├── slug (Text, unique)
├── content (Editor)
├── excerpt (Text)
├── cover (File, single)
├── published (Bool, default: false)
├── author (Relation -> users)
├── categories (Relation -> categories, multiple)
├── tags (JSON)
└── created, updated (auto)

Kolekcja: categories
├── name (Text, required)
├── slug (Text, unique)
└── description (Text)

Kolekcja: comments (Base)
├── content (Text, required)
├── post (Relation -> posts)
├── author (Relation -> users)
└── approved (Bool, default: false)

API Rules (Reguły dostępu)

Dla każdej kolekcji możesz zdefiniować reguły dostępu:

Code
TEXT
List/Search Rule:   published = true || @request.auth.id != ""
View Rule:          published = true || @request.auth.id = author.id
Create Rule:        @request.auth.id != ""
Update Rule:        @request.auth.id = author.id
Delete Rule:        @request.auth.id = author.id

Składnia reguł:

Code
TEXT
# Sprawdzenie czy użytkownik zalogowany
@request.auth.id != ""

# Sprawdzenie pola rekordu
published = true

# Sprawdzenie właściciela
@request.auth.id = author.id

# Sprawdzenie roli użytkownika (custom field w users)
@request.auth.role = "admin"

# Łączenie warunków
(published = true) || (@request.auth.id = author.id)

# Sprawdzenie relacji
@request.auth.id ?= members.id

# Dostęp do danych z request body
@request.data.status = "draft"

JavaScript/TypeScript SDK

Instalacja

Code
Bash
# npm
npm install pocketbase

# yarn
yarn add pocketbase

# pnpm
pnpm add pocketbase

Podstawowa konfiguracja

Code
TypeScript
import PocketBase from 'pocketbase'

// Inicjalizacja klienta
const pb = new PocketBase('http://127.0.0.1:8090')

// Opcjonalnie: automatyczne odświeżanie tokenu
pb.autoCancellation(false)

// Eksport dla użycia w całej aplikacji
export default pb

Konfiguracja z Next.js

TSlib/pocketbase.ts
TypeScript
// lib/pocketbase.ts
import PocketBase from 'pocketbase'

// Singleton pattern dla server-side
let pb: PocketBase | null = null

export function getPocketBase(): PocketBase {
  if (!pb) {
    pb = new PocketBase(process.env.NEXT_PUBLIC_POCKETBASE_URL)
  }
  return pb
}

// Hook dla client-side z auth state
// hooks/usePocketBase.ts
import PocketBase from 'pocketbase'
import { useEffect, useState } from 'react'

export function usePocketBase() {
  const [pb] = useState(() => new PocketBase(
    process.env.NEXT_PUBLIC_POCKETBASE_URL
  ))
  const [user, setUser] = useState(pb.authStore.model)

  useEffect(() => {
    // Nasłuchuj zmian auth state
    const unsubscribe = pb.authStore.onChange((token, model) => {
      setUser(model)
    })

    return () => unsubscribe()
  }, [pb])

  return { pb, user }
}

Typy TypeScript

TStypes/pocketbase.ts
TypeScript
// types/pocketbase.ts
import PocketBase, { RecordModel } from 'pocketbase'

// Definicja typów dla kolekcji
export interface User extends RecordModel {
  email: string
  name: string
  avatar?: string
  role: 'user' | 'admin'
}

export interface Post extends RecordModel {
  title: string
  slug: string
  content: string
  excerpt?: string
  cover?: string
  published: boolean
  author: string // ID użytkownika
  categories: string[] // IDs kategorii
  tags?: string[]
  // Expanded relations
  expand?: {
    author?: User
    categories?: Category[]
  }
}

export interface Category extends RecordModel {
  name: string
  slug: string
  description?: string
}

// Typed PocketBase client
export type TypedPocketBase = PocketBase & {
  collection(name: 'users'): ReturnType<PocketBase['collection']>
  collection(name: 'posts'): ReturnType<PocketBase['collection']>
  collection(name: 'categories'): ReturnType<PocketBase['collection']>
}

Autentykacja

Email/Password

Code
TypeScript
import PocketBase from 'pocketbase'

const pb = new PocketBase('http://127.0.0.1:8090')

// Rejestracja nowego użytkownika
async function register(email: string, password: string, name: string) {
  try {
    const user = await pb.collection('users').create({
      email,
      password,
      passwordConfirm: password,
      name,
      emailVisibility: true
    })

    // Opcjonalnie: wyślij email weryfikacyjny
    await pb.collection('users').requestVerification(email)

    return user
  } catch (error) {
    console.error('Registration failed:', error)
    throw error
  }
}

// Logowanie
async function login(email: string, password: string) {
  try {
    const authData = await pb.collection('users').authWithPassword(
      email,
      password
    )

    console.log('Logged in user:', authData.record)
    console.log('Auth token:', authData.token)

    // Token jest automatycznie przechowywany w pb.authStore
    return authData
  } catch (error) {
    console.error('Login failed:', error)
    throw error
  }
}

// Wylogowanie
function logout() {
  pb.authStore.clear()
}

// Sprawdzenie czy zalogowany
function isLoggedIn(): boolean {
  return pb.authStore.isValid
}

// Pobranie aktualnego użytkownika
function getCurrentUser() {
  return pb.authStore.model
}

// Odświeżenie tokenu
async function refreshAuth() {
  if (pb.authStore.isValid) {
    await pb.collection('users').authRefresh()
  }
}

OAuth2 Providers

PocketBase obsługuje wielu dostawców OAuth2:

Code
TypeScript
// Dostępni providerzy (konfiguracja w Admin Panel > Settings > Auth providers)
// Google, Facebook, GitHub, GitLab, Discord, Twitter, Microsoft, Spotify, etc.

// Lista dostępnych metod OAuth
async function getOAuthProviders() {
  const methods = await pb.collection('users').listAuthMethods()
  console.log('Available providers:', methods.authProviders)
  return methods.authProviders
}

// Logowanie przez OAuth (np. Google)
async function loginWithGoogle() {
  try {
    const authData = await pb.collection('users').authWithOAuth2({
      provider: 'google',
      // Opcjonalnie: custom scopes
      scopes: ['email', 'profile']
    })

    console.log('OAuth user:', authData.record)
    return authData
  } catch (error) {
    console.error('OAuth login failed:', error)
    throw error
  }
}

// OAuth z przekierowaniem (dla SSR)
async function loginWithOAuth2Redirect(provider: string) {
  const authMethods = await pb.collection('users').listAuthMethods()
  const providerConfig = authMethods.authProviders.find(p => p.name === provider)

  if (!providerConfig) {
    throw new Error(`Provider ${provider} not found`)
  }

  // Przekieruj użytkownika do providera
  const redirectUrl = `${window.location.origin}/auth/callback`

  // Zapisz state do weryfikacji
  localStorage.setItem('oauth_state', providerConfig.state)
  localStorage.setItem('oauth_verifier', providerConfig.codeVerifier)

  // Przekierowanie
  window.location.href = providerConfig.authUrl + redirectUrl
}

// Callback handler
async function handleOAuthCallback(code: string, state: string) {
  const savedState = localStorage.getItem('oauth_state')
  const codeVerifier = localStorage.getItem('oauth_verifier')

  if (state !== savedState) {
    throw new Error('Invalid OAuth state')
  }

  const authData = await pb.collection('users').authWithOAuth2Code(
    'google',
    code,
    codeVerifier!,
    `${window.location.origin}/auth/callback`
  )

  localStorage.removeItem('oauth_state')
  localStorage.removeItem('oauth_verifier')

  return authData
}

Reset hasła

Code
TypeScript
// Wysłanie emaila z linkiem do resetu
async function requestPasswordReset(email: string) {
  await pb.collection('users').requestPasswordReset(email)
}

// Potwierdzenie resetu (z tokenu w URL)
async function confirmPasswordReset(
  token: string,
  newPassword: string
) {
  await pb.collection('users').confirmPasswordReset(
    token,
    newPassword,
    newPassword
  )
}

// Zmiana hasła zalogowanego użytkownika
async function changePassword(
  oldPassword: string,
  newPassword: string
) {
  const userId = pb.authStore.model?.id
  if (!userId) throw new Error('Not logged in')

  await pb.collection('users').update(userId, {
    oldPassword,
    password: newPassword,
    passwordConfirm: newPassword
  })
}

Weryfikacja email

Code
TypeScript
// Wysłanie emaila weryfikacyjnego
async function requestEmailVerification(email: string) {
  await pb.collection('users').requestVerification(email)
}

// Potwierdzenie weryfikacji (z tokenu w URL)
async function confirmEmailVerification(token: string) {
  await pb.collection('users').confirmVerification(token)
}

Operacje CRUD

Create (tworzenie)

Code
TypeScript
// Tworzenie pojedynczego rekordu
async function createPost(data: {
  title: string
  content: string
  author: string
}) {
  const post = await pb.collection('posts').create({
    title: data.title,
    content: data.content,
    author: data.author,
    slug: slugify(data.title),
    published: false
  })

  return post
}

// Tworzenie z plikiem
async function createPostWithCover(
  data: {
    title: string
    content: string
    author: string
  },
  coverFile: File
) {
  const formData = new FormData()
  formData.append('title', data.title)
  formData.append('content', data.content)
  formData.append('author', data.author)
  formData.append('cover', coverFile)

  const post = await pb.collection('posts').create(formData)
  return post
}

Read (odczyt)

Code
TypeScript
// Pobierz pojedynczy rekord po ID
async function getPostById(id: string) {
  const post = await pb.collection('posts').getOne(id, {
    expand: 'author,categories'
  })
  return post
}

// Pobierz pierwszy pasujący rekord
async function getPostBySlug(slug: string) {
  const post = await pb.collection('posts').getFirstListItem(
    `slug = "${slug}"`,
    { expand: 'author,categories' }
  )
  return post
}

// Lista z paginacją
async function getPosts(page = 1, perPage = 20) {
  const posts = await pb.collection('posts').getList(page, perPage, {
    filter: 'published = true',
    sort: '-created',
    expand: 'author,categories'
  })

  return {
    items: posts.items,
    page: posts.page,
    perPage: posts.perPage,
    totalItems: posts.totalItems,
    totalPages: posts.totalPages
  }
}

// Pobierz wszystkie rekordy (uwaga na performance!)
async function getAllPosts() {
  const posts = await pb.collection('posts').getFullList({
    filter: 'published = true',
    sort: '-created',
    expand: 'author'
  })
  return posts
}

// Pobierz tylko ID dla statycznych ścieżek
async function getAllPostSlugs() {
  const posts = await pb.collection('posts').getFullList({
    fields: 'slug'
  })
  return posts.map(p => p.slug)
}

Update (aktualizacja)

Code
TypeScript
// Aktualizacja całego rekordu
async function updatePost(id: string, data: Partial<Post>) {
  const post = await pb.collection('posts').update(id, data)
  return post
}

// Aktualizacja z nowym plikiem (zastąpi stary)
async function updatePostCover(id: string, coverFile: File) {
  const formData = new FormData()
  formData.append('cover', coverFile)

  const post = await pb.collection('posts').update(id, formData)
  return post
}

// Usunięcie pliku (ustaw na null)
async function removePostCover(id: string) {
  const post = await pb.collection('posts').update(id, {
    cover: null
  })
  return post
}

Delete (usuwanie)

Code
TypeScript
// Usunięcie pojedynczego rekordu
async function deletePost(id: string) {
  await pb.collection('posts').delete(id)
}

// Usunięcie wielu rekordów (batch)
async function deleteMultiplePosts(ids: string[]) {
  await Promise.all(
    ids.map(id => pb.collection('posts').delete(id))
  )
}

Filtry i sortowanie

Składnia filtrów

Code
TypeScript
// Podstawowe porównania
await pb.collection('posts').getList(1, 20, {
  filter: 'published = true'
})

// Porównanie liczb
await pb.collection('products').getList(1, 20, {
  filter: 'price >= 100 && price <= 500'
})

// Wyszukiwanie tekstowe (LIKE)
await pb.collection('posts').getList(1, 20, {
  filter: 'title ~ "JavaScript"'
})

// Wyszukiwanie case-insensitive
await pb.collection('posts').getList(1, 20, {
  filter: 'title ~ "%react%"'
})

// Sprawdzenie czy pole nie jest puste
await pb.collection('posts').getList(1, 20, {
  filter: 'cover != ""'
})

// Sprawdzenie daty
await pb.collection('posts').getList(1, 20, {
  filter: 'created >= "2024-01-01 00:00:00"'
})

// Sprawdzenie obecności w tablicy (dla relacji many)
await pb.collection('posts').getList(1, 20, {
  filter: 'categories ?~ "abc123"'  // zawiera kategorię o ID
})

// Kombinacja filtrów
await pb.collection('posts').getList(1, 20, {
  filter: `
    published = true &&
    (title ~ "${searchTerm}" || content ~ "${searchTerm}") &&
    author = "${authorId}"
  `
})

Sortowanie

Code
TypeScript
// Sortowanie rosnąco
await pb.collection('posts').getList(1, 20, {
  sort: 'created'
})

// Sortowanie malejąco (prefix -)
await pb.collection('posts').getList(1, 20, {
  sort: '-created'
})

// Wielokrotne sortowanie
await pb.collection('posts').getList(1, 20, {
  sort: '-published,created'  // najpierw published (desc), potem created (asc)
})

// Sortowanie po relacji
await pb.collection('posts').getList(1, 20, {
  sort: 'author.name'
})

// Losowe sortowanie
await pb.collection('posts').getList(1, 20, {
  sort: '@random'
})

Wybór pól i expand

Code
TypeScript
// Tylko wybrane pola (oszczędność bandwidth)
await pb.collection('posts').getList(1, 20, {
  fields: 'id,title,slug,created'
})

// Expand relacji (dodaj dane z powiązanych kolekcji)
await pb.collection('posts').getList(1, 20, {
  expand: 'author,categories'
})

// Dostęp do expanded data
const posts = await pb.collection('posts').getList(1, 20, {
  expand: 'author'
})

posts.items.forEach(post => {
  console.log('Author name:', post.expand?.author?.name)
})

// Nested expand (relacja w relacji)
await pb.collection('comments').getList(1, 20, {
  expand: 'post.author'  // komentarz -> post -> autor
})

// Indirect expand (back-relations)
await pb.collection('users').getList(1, 20, {
  expand: 'posts_via_author'  // posty gdzie user jest autorem
})

Realtime Subscriptions

Subskrypcja kolekcji

Code
TypeScript
// Subskrybuj wszystkie zmiany w kolekcji
pb.collection('posts').subscribe('*', (e) => {
  console.log('Action:', e.action)  // create, update, delete
  console.log('Record:', e.record)

  switch (e.action) {
    case 'create':
      addPostToUI(e.record)
      break
    case 'update':
      updatePostInUI(e.record)
      break
    case 'delete':
      removePostFromUI(e.record.id)
      break
  }
})

// Subskrybuj konkretny rekord
pb.collection('posts').subscribe('RECORD_ID', (e) => {
  console.log('Post updated:', e.record)
})

// Subskrypcja z filtrem (client-side)
pb.collection('posts').subscribe('*', (e) => {
  if (e.record.published) {
    handlePublishedPost(e.record)
  }
})

Zarządzanie subskrypcjami

Code
TypeScript
// Anuluj subskrypcję dla konkretnej kolekcji
pb.collection('posts').unsubscribe('*')
pb.collection('posts').unsubscribe('RECORD_ID')

// Anuluj wszystkie subskrypcje
pb.realtime.unsubscribe()

// React Hook dla realtime
function useRealtimePosts() {
  const [posts, setPosts] = useState<Post[]>([])
  const pb = usePocketBase()

  useEffect(() => {
    // Pobierz początkowe dane
    pb.collection('posts').getFullList({
      filter: 'published = true',
      sort: '-created'
    }).then(setPosts)

    // Subskrybuj zmiany
    pb.collection('posts').subscribe('*', (e) => {
      setPosts(current => {
        switch (e.action) {
          case 'create':
            if (e.record.published) {
              return [e.record as Post, ...current]
            }
            return current
          case 'update':
            return current.map(p =>
              p.id === e.record.id ? e.record as Post : p
            )
          case 'delete':
            return current.filter(p => p.id !== e.record.id)
          default:
            return current
        }
      })
    })

    // Cleanup
    return () => {
      pb.collection('posts').unsubscribe('*')
    }
  }, [pb])

  return posts
}

Realtime dla autoryzowanych użytkowników

Code
TypeScript
// Subskrypcje działają tylko dla rekordów,
// do których użytkownik ma dostęp (zgodnie z API rules)

// Przykład: prywatne wiadomości
pb.collection('messages').subscribe('*', (e) => {
  // Otrzymasz tylko wiadomości, gdzie:
  // sender = @request.auth.id || receiver = @request.auth.id
  console.log('New message:', e.record)
})

File Storage

Upload plików

Code
TypeScript
// Upload pojedynczego pliku
async function uploadAvatar(userId: string, file: File) {
  const formData = new FormData()
  formData.append('avatar', file)

  const user = await pb.collection('users').update(userId, formData)
  return user
}

// Upload wielu plików
async function uploadGalleryImages(postId: string, files: FileList) {
  const formData = new FormData()

  // Dla pola typu File z opcją "multiple"
  for (let i = 0; i < files.length; i++) {
    formData.append('gallery', files[i])
  }

  const post = await pb.collection('posts').update(postId, formData)
  return post
}

// Upload z progress tracking (fetch API)
async function uploadWithProgress(
  collectionName: string,
  recordId: string,
  fieldName: string,
  file: File,
  onProgress: (percent: number) => void
) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest()
    const formData = new FormData()
    formData.append(fieldName, file)

    xhr.upload.addEventListener('progress', (e) => {
      if (e.lengthComputable) {
        onProgress(Math.round((e.loaded / e.total) * 100))
      }
    })

    xhr.addEventListener('load', () => {
      if (xhr.status >= 200 && xhr.status < 300) {
        resolve(JSON.parse(xhr.responseText))
      } else {
        reject(new Error(xhr.statusText))
      }
    })

    xhr.addEventListener('error', () => reject(new Error('Upload failed')))

    xhr.open('PATCH', `${pb.baseUrl}/api/collections/${collectionName}/records/${recordId}`)
    xhr.setRequestHeader('Authorization', pb.authStore.token)
    xhr.send(formData)
  })
}

Pobieranie URL plików

Code
TypeScript
// Pobierz URL do pliku
const avatarUrl = pb.files.getUrl(user, user.avatar)

// Z thumbnailem (dla obrazów)
const thumbUrl = pb.files.getUrl(user, user.avatar, {
  thumb: '100x100'
})

// Dostępne opcje thumb:
// - '100x100' - resize do dokładnie 100x100
// - '100x0' - resize zachowując proporcje (szerokość 100)
// - '0x100' - resize zachowując proporcje (wysokość 100)
// - '100x100t' - crop do środka
// - '100x100b' - crop od dołu
// - '100x100f' - fit (zmieść w 100x100 zachowując proporcje)

// Dla wielu plików (pole z multiple)
const galleryUrls = post.gallery.map(filename =>
  pb.files.getUrl(post, filename)
)

// Helper dla Next.js Image
function getPocketBaseImageUrl(
  record: RecordModel,
  filename: string,
  thumb?: string
): string {
  if (!filename) return '/placeholder.png'

  const url = pb.files.getUrl(record, filename, { thumb })
  return url
}

// Użycie w komponencie
<Image
  src={getPocketBaseImageUrl(user, user.avatar, '200x200')}
  alt={user.name}
  width={200}
  height={200}
/>

Usuwanie plików

Code
TypeScript
// Usuń konkretny plik (ustaw na null lub pusty string)
await pb.collection('users').update(userId, {
  avatar: null
})

// Dla pola z wieloma plikami - usuń jeden
await pb.collection('posts').update(postId, {
  'gallery-': 'filename-to-remove.jpg'  // prefix - oznacza usunięcie
})

// Dodaj nowy plik do istniejącej kolekcji
const formData = new FormData()
formData.append('gallery+', newFile)  // prefix + oznacza dodanie
await pb.collection('posts').update(postId, formData)

Rozszerzanie PocketBase w Go

Custom backend

main.go
Go
// main.go
package main

import (
    "log"
    "net/http"

    "github.com/labstack/echo/v5"
    "github.com/pocketbase/pocketbase"
    "github.com/pocketbase/pocketbase/core"
    "github.com/pocketbase/pocketbase/apis"
)

func main() {
    app := pocketbase.New()

    // Custom route - publiczny endpoint
    app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
        e.Router.GET("/api/hello", func(c echo.Context) error {
            return c.JSON(http.StatusOK, map[string]string{
                "message": "Hello from PocketBase!",
            })
        })
        return nil
    })

    // Custom route - chroniony (wymaga auth)
    app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
        e.Router.GET("/api/protected", func(c echo.Context) error {
            authRecord, _ := c.Get(apis.ContextAuthRecordKey).(*models.Record)

            if authRecord == nil {
                return apis.NewForbiddenError("Unauthorized", nil)
            }

            return c.JSON(http.StatusOK, map[string]any{
                "user": authRecord.Email(),
                "message": "Welcome!",
            })
        }, apis.RequireRecordAuth())
        return nil
    })

    if err := app.Start(); err != nil {
        log.Fatal(err)
    }
}

Hooki na eventy

Code
Go
package main

import (
    "log"

    "github.com/pocketbase/pocketbase"
    "github.com/pocketbase/pocketbase/core"
)

func main() {
    app := pocketbase.New()

    // Hook przed utworzeniem rekordu
    app.OnRecordBeforeCreateRequest("posts").Add(func(e *core.RecordCreateEvent) error {
        // Automatycznie ustaw autora na zalogowanego użytkownika
        if authRecord := e.HttpContext.Get(apis.ContextAuthRecordKey); authRecord != nil {
            e.Record.Set("author", authRecord.(*models.Record).Id)
        }

        // Generuj slug z tytułu
        title := e.Record.GetString("title")
        slug := slugify(title)
        e.Record.Set("slug", slug)

        return nil
    })

    // Hook po utworzeniu rekordu
    app.OnRecordAfterCreateRequest("posts").Add(func(e *core.RecordCreateEvent) error {
        // Wyślij notyfikację
        log.Printf("New post created: %s", e.Record.GetString("title"))

        // Wyślij email do adminów
        // sendEmailToAdmins(e.Record)

        return nil
    })

    // Hook przed aktualizacją
    app.OnRecordBeforeUpdateRequest("posts").Add(func(e *core.RecordUpdateEvent) error {
        // Sprawdź czy użytkownik może edytować
        authRecord := e.HttpContext.Get(apis.ContextAuthRecordKey).(*models.Record)

        if e.Record.GetString("author") != authRecord.Id {
            // Sprawdź czy admin
            if authRecord.GetString("role") != "admin" {
                return apis.NewForbiddenError("You can only edit your own posts", nil)
            }
        }

        return nil
    })

    // Hook na uwierzytelnienie
    app.OnRecordAuthRequest("users").Add(func(e *core.RecordAuthEvent) error {
        log.Printf("User logged in: %s", e.Record.Email())
        return nil
    })

    if err := app.Start(); err != nil {
        log.Fatal(err)
    }
}

Scheduled tasks (cron)

Code
Go
package main

import (
    "github.com/pocketbase/pocketbase"
    "github.com/pocketbase/pocketbase/tools/cron"
)

func main() {
    app := pocketbase.New()

    // Scheduler uruchamiany co godzinę
    app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
        scheduler := cron.New()

        // Co godzinę - cleanup starych sesji
        scheduler.MustAdd("cleanup", "0 * * * *", func() {
            log.Println("Running cleanup...")
            // Implementacja cleanup
        })

        // Codziennie o północy - backup
        scheduler.MustAdd("backup", "0 0 * * *", func() {
            log.Println("Running backup...")
            // app.CreateBackup("backup_" + time.Now().Format("2006-01-02"))
        })

        // Co 5 minut - sprawdź zaplanowane posty
        scheduler.MustAdd("publish", "*/5 * * * *", func() {
            publishScheduledPosts(app)
        })

        scheduler.Start()

        return nil
    })

    if err := app.Start(); err != nil {
        log.Fatal(err)
    }
}

func publishScheduledPosts(app *pocketbase.PocketBase) {
    records, err := app.Dao().FindRecordsByFilter(
        "posts",
        "published = false && scheduled_at <= {:now}",
        "-scheduled_at",
        100,
        0,
        dbx.Params{"now": time.Now().UTC().Format(time.RFC3339)},
    )

    if err != nil {
        log.Printf("Error finding scheduled posts: %v", err)
        return
    }

    for _, record := range records {
        record.Set("published", true)
        if err := app.Dao().SaveRecord(record); err != nil {
            log.Printf("Error publishing post %s: %v", record.Id, err)
        }
    }
}

Deployment

Docker

Code
DOCKERFILE
# Dockerfile
FROM alpine:latest

ARG PB_VERSION=0.22.0

RUN apk add --no-cache \
    unzip \
    ca-certificates

# Download 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/

# Create data directory
RUN mkdir -p /pb/pb_data

EXPOSE 8090

# Start PocketBase
CMD ["/pb/pocketbase", "serve", "--http=0.0.0.0:8090"]
docker-compose.yml
YAML
# docker-compose.yml
version: '3.8'

services:
  pocketbase:
    build: .
    container_name: pocketbase
    restart: unless-stopped
    ports:
      - "8090:8090"
    volumes:
      - pocketbase_data:/pb/pb_data
      - ./pb_migrations:/pb/pb_migrations
    healthcheck:
      test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8090/api/health"]
      interval: 30s
      timeout: 10s
      retries: 3

volumes:
  pocketbase_data:

Railway

railway.toml
TOML
# railway.toml
[build]
builder = "dockerfile"

[deploy]
startCommand = "/pb/pocketbase serve --http=0.0.0.0:$PORT"
healthcheckPath = "/api/health"
healthcheckTimeout = 100
restartPolicyType = "ON_FAILURE"
restartPolicyMaxRetries = 10

Fly.io

fly.toml
TOML
# fly.toml
app = "my-pocketbase-app"
primary_region = "waw"

[build]
  dockerfile = "Dockerfile"

[http_service]
  internal_port = 8090
  force_https = true
  auto_stop_machines = false
  auto_start_machines = true
  min_machines_running = 1

[mounts]
  source = "pb_data"
  destination = "/pb/pb_data"

[[vm]]
  cpu_kind = "shared"
  cpus = 1
  memory_mb = 256
Code
Bash
# Deploy na Fly.io
fly launch
fly volumes create pb_data --region waw --size 1
fly deploy

VPS (systemd)

/etc/systemd/system/pocketbase.service
Bash
# /etc/systemd/system/pocketbase.service
[Unit]
Description=PocketBase
After=network.target

[Service]
Type=simple
User=pocketbase
Group=pocketbase
LimitNOFILE=4096
Restart=always
RestartSec=5s
WorkingDirectory=/home/pocketbase
ExecStart=/home/pocketbase/pocketbase serve --http="0.0.0.0:8090"

[Install]
WantedBy=multi-user.target
Code
Bash
# Instalacja
sudo useradd -r -s /bin/false pocketbase
sudo mkdir -p /home/pocketbase
sudo chown pocketbase:pocketbase /home/pocketbase

# Skopiuj pocketbase do /home/pocketbase/

# Uruchom
sudo systemctl enable pocketbase
sudo systemctl start pocketbase
sudo systemctl status pocketbase

Nginx Reverse Proxy

Code
NGINX
# /etc/nginx/sites-available/pocketbase
server {
    listen 80;
    server_name api.example.com;

    # Redirect HTTP to HTTPS
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    server_name api.example.com;

    ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;

    # Security headers
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;

    # Max upload size
    client_max_body_size 50M;

    location / {
        proxy_pass http://127.0.0.1:8090;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # WebSocket support
        proxy_read_timeout 86400;
    }
}

Backup i migracje

Automatyczne backupy

Code
Go
// Konfiguracja backupów w Go
package main

import (
    "github.com/pocketbase/pocketbase"
    "github.com/pocketbase/pocketbase/tools/cron"
)

func main() {
    app := pocketbase.New()

    app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
        scheduler := cron.New()

        // Backup codziennie o 3:00
        scheduler.MustAdd("backup", "0 3 * * *", func() {
            name := fmt.Sprintf("backup_%s.zip", time.Now().Format("2006-01-02"))

            if err := app.CreateBackup(name); err != nil {
                log.Printf("Backup failed: %v", err)
                return
            }

            log.Printf("Backup created: %s", name)

            // Opcjonalnie: upload do S3
            // uploadToS3(name)
        })

        scheduler.Start()
        return nil
    })

    app.Start()
}

Ręczne backupy

Code
Bash
# Backup przez Admin UI
# Settings > Backups > Create backup

# Backup przez command line
./pocketbase --dir=/path/to/pb_data backup

# Kopia katalogów
cp -r pb_data pb_data_backup_$(date +%Y%m%d)

# Restore
./pocketbase --dir=/path/to/pb_data restore /path/to/backup.zip

Migracje

pb_migrations/1234567890_create_posts.go
Go
// pb_migrations/1234567890_create_posts.go
package migrations

import (
    "github.com/pocketbase/dbx"
    "github.com/pocketbase/pocketbase/daos"
    m "github.com/pocketbase/pocketbase/migrations"
    "github.com/pocketbase/pocketbase/models"
    "github.com/pocketbase/pocketbase/models/schema"
)

func init() {
    m.Register(func(db dbx.Builder) error {
        collection := &models.Collection{}
        collection.Name = "posts"
        collection.Type = models.CollectionTypeBase
        collection.Schema = schema.NewSchema(
            &schema.SchemaField{
                Name:     "title",
                Type:     schema.FieldTypeText,
                Required: true,
            },
            &schema.SchemaField{
                Name: "content",
                Type: schema.FieldTypeEditor,
            },
            &schema.SchemaField{
                Name: "published",
                Type: schema.FieldTypeBool,
            },
        )

        return daos.New(db).SaveCollection(collection)
    }, func(db dbx.Builder) error {
        // Rollback
        collection, err := daos.New(db).FindCollectionByNameOrId("posts")
        if err != nil {
            return err
        }
        return daos.New(db).DeleteCollection(collection)
    })
}
Code
Bash
# Generowanie migracji ze zmian w Admin UI
./pocketbase migrate collections

# Zastosowanie migracji
./pocketbase migrate up

# Rollback
./pocketbase migrate down

Integracja z frameworkami

Next.js App Router

TSapp/posts/page.tsx
TypeScript
// app/posts/page.tsx
import { getPocketBase } from '@/lib/pocketbase'

export const revalidate = 60 // ISR - revalidate co 60 sekund

export default async function PostsPage() {
  const pb = getPocketBase()

  const posts = await pb.collection('posts').getList(1, 10, {
    filter: 'published = true',
    sort: '-created',
    expand: 'author'
  })

  return (
    <div>
      <h1>Blog Posts</h1>
      <ul>
        {posts.items.map(post => (
          <li key={post.id}>
            <a href={`/posts/${post.slug}`}>{post.title}</a>
            <span>by {post.expand?.author?.name}</span>
          </li>
        ))}
      </ul>
    </div>
  )
}
TSapp/posts/[slug]/page.tsx
TypeScript
// app/posts/[slug]/page.tsx
import { getPocketBase } from '@/lib/pocketbase'
import { notFound } from 'next/navigation'

export async function generateStaticParams() {
  const pb = getPocketBase()
  const posts = await pb.collection('posts').getFullList({
    fields: 'slug'
  })

  return posts.map(post => ({
    slug: post.slug
  }))
}

export default async function PostPage({
  params
}: {
  params: { slug: string }
}) {
  const pb = getPocketBase()

  try {
    const post = await pb.collection('posts').getFirstListItem(
      `slug = "${params.slug}" && published = true`,
      { expand: 'author,categories' }
    )

    return (
      <article>
        <h1>{post.title}</h1>
        <div dangerouslySetInnerHTML={{ __html: post.content }} />
      </article>
    )
  } catch {
    notFound()
  }
}

SvelteKit

TSsrc/lib/pocketbase.ts
TypeScript
// src/lib/pocketbase.ts
import PocketBase from 'pocketbase'
import { writable } from 'svelte/store'

export const pb = new PocketBase(import.meta.env.VITE_POCKETBASE_URL)

export const currentUser = writable(pb.authStore.model)

pb.authStore.onChange((auth) => {
  currentUser.set(pb.authStore.model)
})
src/routes/posts/+page.svelte
SVELTE
<!-- src/routes/posts/+page.svelte -->
<script lang="ts">
  import { pb } from '$lib/pocketbase'
  import { onMount } from 'svelte'

  let posts = []

  onMount(async () => {
    posts = await pb.collection('posts').getFullList({
      filter: 'published = true',
      sort: '-created'
    })
  })
</script>

<h1>Posts</h1>
{#each posts as post}
  <article>
    <h2><a href="/posts/{post.slug}">{post.title}</a></h2>
  </article>
{/each}

React Native / Expo

TSlib/pocketbase.ts
TypeScript
// lib/pocketbase.ts
import PocketBase from 'pocketbase'
import AsyncStorage from '@react-native-async-storage/async-storage'

// Custom auth store dla React Native
const pb = new PocketBase('https://api.example.com')

// Persist auth w AsyncStorage
pb.authStore.onChange(async () => {
  try {
    if (pb.authStore.isValid) {
      await AsyncStorage.setItem('pb_auth', JSON.stringify({
        token: pb.authStore.token,
        model: pb.authStore.model
      }))
    } else {
      await AsyncStorage.removeItem('pb_auth')
    }
  } catch (e) {
    console.error('Error saving auth state:', e)
  }
})

// Restore auth przy starcie aplikacji
export async function initPocketBase() {
  try {
    const authData = await AsyncStorage.getItem('pb_auth')
    if (authData) {
      const { token, model } = JSON.parse(authData)
      pb.authStore.save(token, model)
    }
  } catch (e) {
    console.error('Error restoring auth state:', e)
  }
  return pb
}

export default pb

Best practices

Struktura projektu

Code
TEXT
my-app/
├── pocketbase/
│   ├── pocketbase           # Binary
│   ├── pb_data/            # Data directory
│   ├── pb_migrations/      # Migrations
│   └── main.go             # Custom backend (opcjonalnie)
├── frontend/
│   ├── src/
│   │   ├── lib/
│   │   │   └── pocketbase.ts
│   │   ├── types/
│   │   │   └── pocketbase.ts
│   │   └── hooks/
│   │       └── usePocketBase.ts
│   └── ...
└── docker-compose.yml

Bezpieczeństwo

Code
TypeScript
// 1. Walidacja danych wejściowych
function validatePostData(data: unknown): data is CreatePostInput {
  if (typeof data !== 'object' || data === null) return false

  const { title, content } = data as Record<string, unknown>

  return (
    typeof title === 'string' &&
    title.length > 0 &&
    title.length <= 200 &&
    typeof content === 'string'
  )
}

// 2. Sanityzacja HTML (dla pól Editor)
import DOMPurify from 'dompurify'

function sanitizeContent(html: string): string {
  return DOMPurify.sanitize(html, {
    ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'a', 'h2', 'h3'],
    ALLOWED_ATTR: ['href', 'target', 'rel']
  })
}

// 3. Ochrona przed SQL injection (już wbudowane w PocketBase)
// Używaj zawsze parametryzowanych zapytań
const posts = await pb.collection('posts').getList(1, 20, {
  filter: `author = "${sanitizedUserId}"` // PocketBase escapuje automatycznie
})

// 4. Rate limiting (w custom Go backend)
// Implementuj w middleware

Obsługa błędów

Code
TypeScript
import PocketBase, { ClientResponseError } from 'pocketbase'

async function safePocketBaseCall<T>(
  operation: () => Promise<T>
): Promise<{ data: T | null; error: string | null }> {
  try {
    const data = await operation()
    return { data, error: null }
  } catch (err) {
    if (err instanceof ClientResponseError) {
      // Błąd z PocketBase API
      console.error('PocketBase error:', err.status, err.message)

      switch (err.status) {
        case 400:
          return { data: null, error: 'Invalid request data' }
        case 401:
          return { data: null, error: 'Please login to continue' }
        case 403:
          return { data: null, error: 'You don\'t have permission' }
        case 404:
          return { data: null, error: 'Resource not found' }
        default:
          return { data: null, error: 'Something went wrong' }
      }
    }

    // Inny błąd
    console.error('Unknown error:', err)
    return { data: null, error: 'An unexpected error occurred' }
  }
}

// Użycie
const { data: posts, error } = await safePocketBaseCall(() =>
  pb.collection('posts').getList(1, 20)
)

if (error) {
  showToast(error)
} else {
  displayPosts(posts)
}

Optymalizacja wydajności

Code
TypeScript
// 1. Pobieraj tylko potrzebne pola
const posts = await pb.collection('posts').getList(1, 20, {
  fields: 'id,title,slug,excerpt,created'  // Bez content
})

// 2. Używaj expand zamiast wielu zapytań
const post = await pb.collection('posts').getOne(id, {
  expand: 'author,categories'  // 1 zapytanie zamiast 3
})

// 3. Cachuj na client-side
const CACHE_TTL = 5 * 60 * 1000 // 5 minut

const cache = new Map<string, { data: any; timestamp: number }>()

async function getCachedPosts() {
  const cacheKey = 'posts_list'
  const cached = cache.get(cacheKey)

  if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
    return cached.data
  }

  const posts = await pb.collection('posts').getList(1, 20)
  cache.set(cacheKey, { data: posts, timestamp: Date.now() })

  return posts
}

// 4. Lazy loading dla dużych list
async function loadMorePosts(page: number) {
  const posts = await pb.collection('posts').getList(page, 20, {
    filter: 'published = true',
    sort: '-created'
  })

  return {
    items: posts.items,
    hasMore: posts.page < posts.totalPages
  }
}

FAQ - Najczęściej zadawane pytania

Czy PocketBase nadaje się do produkcji?

Tak, PocketBase jest używany w produkcji przez wiele aplikacji. Ograniczenia:

  • SQLite ma limity dla bardzo dużego ruchu (>1000 req/s write)
  • Brak horizontal scaling (single instance)
  • Odpowiedni dla małych/średnich aplikacji (do ~100K użytkowników)

Jak wykonać backup PocketBase?

Code
Bash
# 1. Przez Admin UI: Settings > Backups
# 2. Przez CLI: ./pocketbase backup
# 3. Ręcznie: cp -r pb_data/ backup/

Czy mogę używać PostgreSQL zamiast SQLite?

Nie, PocketBase używa wyłącznie SQLite. Jeśli potrzebujesz PostgreSQL, rozważ Supabase lub własny backend z Prisma.

Jak zaimportować dane do PocketBase?

Code
TypeScript
// Import przez SDK
async function importData(data: any[]) {
  for (const item of data) {
    await pb.collection('posts').create(item)
  }
}

// Lub przez Admin API
// Settings > Collections > Import

Jak skonfigurować CORS?

PocketBase ma wbudowaną obsługę CORS. Domyślnie akceptuje wszystkie origins. Możesz to zmienić w custom backend:

Code
Go
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
    e.Router.Use(middleware.CORSWithConfig(middleware.CORSConfig{
        AllowOrigins: []string{"https://example.com"},
        AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete},
    }))
    return nil
})

Jak wysyłać emaile z PocketBase?

Skonfiguruj SMTP w Admin Panel > Settings > Mail settings:

Code
TEXT
SMTP Server: smtp.gmail.com
Port: 587
Username: your-email@gmail.com
Password: app-specific-password

Jak debugować zapytania?

Code
TypeScript
// Włącz debug mode
pb.beforeSend = function (url, options) {
  console.log('Request:', url, options)
  return { url, options }
}

pb.afterSend = function (response, data) {
  console.log('Response:', response.status, data)
  return data
}

Podsumowanie

PocketBase to rewolucyjne podejście do backendów aplikacji. Oferuje:

  • Prostotę - Jeden plik zamiast dziesiątków serwisów
  • Wydajność - SQLite + Go = szybki backend
  • Kompletność - Auth, realtime, files, admin panel
  • Elastyczność - Rozszerzalny w Go
  • Kontrolę - Self-hosted, Twoje dane

Dla projektów MVP, side-projects i małych/średnich aplikacji PocketBase jest idealnym wyborem. Uruchamiasz jeden plik i masz działający backend w sekundy - bez konfiguracji, bez kosztów infrastruktury, bez vendor lock-in.


PocketBase - backend in a single file

What is PocketBase?

PocketBase is an open-source application backend compressed into a single executable file roughly 15MB in size. It contains everything you need to run a backend: an SQLite database, an authentication system, realtime subscriptions, file storage, and a built-in admin panel. It is written in Go, which ensures high performance and straightforward deployment.

PocketBase was created by Gani Georgieva as an alternative to sprawling BaaS (Backend as a Service) solutions like Firebase or Supabase. Its philosophy is rooted in simplicity -- instead of configuring dozens of services, you launch a single file and have a working backend in seconds.

Why PocketBase?

Key advantages

  1. Single executable file - The entire backend in one ~15MB file
  2. Zero configuration - Run it and it works right away
  3. SQLite - A proven, fast database for small and medium projects
  4. Built-in Admin UI - Manage data without additional tools
  5. Realtime - WebSocket subscriptions for live updates
  6. Self-hosted - Full control over your data
  7. Free and open-source - MIT License

PocketBase vs other BaaS solutions

FeaturePocketBaseFirebaseSupabaseAppwrite
Self-hostedYesNoYesYes
PriceFreeFreemiumFreemiumFree
DatabaseSQLiteFirestorePostgreSQLMariaDB
Deployment size~15MBCloud~2GB~1GB
ConfigurationZeroMediumHighMedium
RealtimeYesYesYesYes
Admin PanelYesYesYesYes
JS SDKYesYesYesYes
Extendable in GoYesNoNoNo
OAuth providersYesYesYesYes
File StorageYesYesYesYes

When to choose PocketBase?

Ideal use cases:

  • MVPs and prototypes
  • Side projects
  • Small and medium applications (up to ~100K users)
  • Applications requiring self-hosting
  • Projects with a limited budget
  • Quick proof-of-concept builds

Not recommended for:

  • Enterprise applications with millions of users
  • Projects requiring advanced SQL queries
  • Applications with very complex data relationships
  • Systems requiring horizontal scaling

Installation and quick start

Download and run

Code
Bash
# Linux AMD64
wget https://github.com/pocketbase/pocketbase/releases/download/v0.22.0/pocketbase_0.22.0_linux_amd64.zip
unzip pocketbase_0.22.0_linux_amd64.zip

# macOS ARM64 (Apple Silicon)
wget https://github.com/pocketbase/pocketbase/releases/download/v0.22.0/pocketbase_0.22.0_darwin_arm64.zip
unzip pocketbase_0.22.0_darwin_arm64.zip

# macOS AMD64 (Intel)
wget https://github.com/pocketbase/pocketbase/releases/download/v0.22.0/pocketbase_0.22.0_darwin_amd64.zip
unzip pocketbase_0.22.0_darwin_amd64.zip

# Windows
# Download pocketbase_0.22.0_windows_amd64.zip from GitHub Releases

# Start the server
./pocketbase serve

# With a custom port
./pocketbase serve --http="127.0.0.1:8080"

# With public access
./pocketbase serve --http="0.0.0.0:8090"

Directory structure

After launching, PocketBase creates a pb_data/ directory:

Code
TEXT
my-app/
├── pocketbase           # Executable file
├── pb_data/
│   ├── data.db         # SQLite database
│   ├── storage/        # Stored files
│   └── backups/        # Automatic backups
└── pb_migrations/       # Optional migrations

First launch of the Admin UI

After starting the server, navigate to:

Code
TEXT
http://127.0.0.1:8090/_/

On first launch you will be asked to create an administrator account. Enter an email and password -- these will be your credentials for the admin panel.

Admin Panel

Creating collections (tables)

The Admin Panel allows you to visually create and manage collections:

  1. Go to Settings > Collections
  2. Click "New collection"
  3. Choose the collection type:
    • Base - Standard data collection
    • Auth - User collection with authentication
    • View - Read-only view based on other collections

Defining the schema

Available field types:

TypeDescriptionExample
TextShort textname, title
EditorRich text (HTML)article body
NumberNumber (int/float)price, quantity
BoolTrue/falsepublished, active
EmailValidated emailuser email
URLValidated URLwebsite link
DateDate and timecreated_at
SelectOne from a liststatus, category
FileUploaded fileavatar, attachment
RelationRelation to another collectionauthor -> users
JSONAny JSON objectmetadata, settings

Example blog structure

Code
TEXT
Collection: posts
├── title (Text, required)
├── slug (Text, unique)
├── content (Editor)
├── excerpt (Text)
├── cover (File, single)
├── published (Bool, default: false)
├── author (Relation -> users)
├── categories (Relation -> categories, multiple)
├── tags (JSON)
└── created, updated (auto)

Collection: categories
├── name (Text, required)
├── slug (Text, unique)
└── description (Text)

Collection: comments (Base)
├── content (Text, required)
├── post (Relation -> posts)
├── author (Relation -> users)
└── approved (Bool, default: false)

API rules (access rules)

For each collection you can define access rules:

Code
TEXT
List/Search Rule:   published = true || @request.auth.id != ""
View Rule:          published = true || @request.auth.id = author.id
Create Rule:        @request.auth.id != ""
Update Rule:        @request.auth.id = author.id
Delete Rule:        @request.auth.id = author.id

Rule syntax:

Code
TEXT
# Check if the user is logged in
@request.auth.id != ""

# Check a record field
published = true

# Check the owner
@request.auth.id = author.id

# Check the user role (custom field in users)
@request.auth.role = "admin"

# Combining conditions
(published = true) || (@request.auth.id = author.id)

# Check a relation
@request.auth.id ?= members.id

# Access data from the request body
@request.data.status = "draft"

JavaScript/TypeScript SDK

Installation

Code
Bash
# npm
npm install pocketbase

# yarn
yarn add pocketbase

# pnpm
pnpm add pocketbase

Basic configuration

Code
TypeScript
import PocketBase from 'pocketbase'

const pb = new PocketBase('http://127.0.0.1:8090')

pb.autoCancellation(false)

export default pb

Configuration with Next.js

TSlib/pocketbase.ts
TypeScript
// lib/pocketbase.ts
import PocketBase from 'pocketbase'

let pb: PocketBase | null = null

export function getPocketBase(): PocketBase {
  if (!pb) {
    pb = new PocketBase(process.env.NEXT_PUBLIC_POCKETBASE_URL)
  }
  return pb
}

// hooks/usePocketBase.ts
import PocketBase from 'pocketbase'
import { useEffect, useState } from 'react'

export function usePocketBase() {
  const [pb] = useState(() => new PocketBase(
    process.env.NEXT_PUBLIC_POCKETBASE_URL
  ))
  const [user, setUser] = useState(pb.authStore.model)

  useEffect(() => {
    const unsubscribe = pb.authStore.onChange((token, model) => {
      setUser(model)
    })

    return () => unsubscribe()
  }, [pb])

  return { pb, user }
}

TypeScript types

TStypes/pocketbase.ts
TypeScript
// types/pocketbase.ts
import PocketBase, { RecordModel } from 'pocketbase'

export interface User extends RecordModel {
  email: string
  name: string
  avatar?: string
  role: 'user' | 'admin'
}

export interface Post extends RecordModel {
  title: string
  slug: string
  content: string
  excerpt?: string
  cover?: string
  published: boolean
  author: string
  categories: string[]
  tags?: string[]
  expand?: {
    author?: User
    categories?: Category[]
  }
}

export interface Category extends RecordModel {
  name: string
  slug: string
  description?: string
}

export type TypedPocketBase = PocketBase & {
  collection(name: 'users'): ReturnType<PocketBase['collection']>
  collection(name: 'posts'): ReturnType<PocketBase['collection']>
  collection(name: 'categories'): ReturnType<PocketBase['collection']>
}

Authentication

Email/password

Code
TypeScript
import PocketBase from 'pocketbase'

const pb = new PocketBase('http://127.0.0.1:8090')

async function register(email: string, password: string, name: string) {
  try {
    const user = await pb.collection('users').create({
      email,
      password,
      passwordConfirm: password,
      name,
      emailVisibility: true
    })

    await pb.collection('users').requestVerification(email)

    return user
  } catch (error) {
    console.error('Registration failed:', error)
    throw error
  }
}

async function login(email: string, password: string) {
  try {
    const authData = await pb.collection('users').authWithPassword(
      email,
      password
    )

    console.log('Logged in user:', authData.record)
    console.log('Auth token:', authData.token)

    return authData
  } catch (error) {
    console.error('Login failed:', error)
    throw error
  }
}

function logout() {
  pb.authStore.clear()
}

function isLoggedIn(): boolean {
  return pb.authStore.isValid
}

function getCurrentUser() {
  return pb.authStore.model
}

async function refreshAuth() {
  if (pb.authStore.isValid) {
    await pb.collection('users').authRefresh()
  }
}

OAuth2 providers

PocketBase supports many OAuth2 providers:

Code
TypeScript
// Available providers (configured in Admin Panel > Settings > Auth providers)
// Google, Facebook, GitHub, GitLab, Discord, Twitter, Microsoft, Spotify, etc.

async function getOAuthProviders() {
  const methods = await pb.collection('users').listAuthMethods()
  console.log('Available providers:', methods.authProviders)
  return methods.authProviders
}

async function loginWithGoogle() {
  try {
    const authData = await pb.collection('users').authWithOAuth2({
      provider: 'google',
      scopes: ['email', 'profile']
    })

    console.log('OAuth user:', authData.record)
    return authData
  } catch (error) {
    console.error('OAuth login failed:', error)
    throw error
  }
}

async function loginWithOAuth2Redirect(provider: string) {
  const authMethods = await pb.collection('users').listAuthMethods()
  const providerConfig = authMethods.authProviders.find(p => p.name === provider)

  if (!providerConfig) {
    throw new Error(`Provider ${provider} not found`)
  }

  const redirectUrl = `${window.location.origin}/auth/callback`

  localStorage.setItem('oauth_state', providerConfig.state)
  localStorage.setItem('oauth_verifier', providerConfig.codeVerifier)

  window.location.href = providerConfig.authUrl + redirectUrl
}

async function handleOAuthCallback(code: string, state: string) {
  const savedState = localStorage.getItem('oauth_state')
  const codeVerifier = localStorage.getItem('oauth_verifier')

  if (state !== savedState) {
    throw new Error('Invalid OAuth state')
  }

  const authData = await pb.collection('users').authWithOAuth2Code(
    'google',
    code,
    codeVerifier!,
    `${window.location.origin}/auth/callback`
  )

  localStorage.removeItem('oauth_state')
  localStorage.removeItem('oauth_verifier')

  return authData
}

Password reset

Code
TypeScript
async function requestPasswordReset(email: string) {
  await pb.collection('users').requestPasswordReset(email)
}

async function confirmPasswordReset(
  token: string,
  newPassword: string
) {
  await pb.collection('users').confirmPasswordReset(
    token,
    newPassword,
    newPassword
  )
}

async function changePassword(
  oldPassword: string,
  newPassword: string
) {
  const userId = pb.authStore.model?.id
  if (!userId) throw new Error('Not logged in')

  await pb.collection('users').update(userId, {
    oldPassword,
    password: newPassword,
    passwordConfirm: newPassword
  })
}

Email verification

Code
TypeScript
async function requestEmailVerification(email: string) {
  await pb.collection('users').requestVerification(email)
}

async function confirmEmailVerification(token: string) {
  await pb.collection('users').confirmVerification(token)
}

CRUD operations

Create

Code
TypeScript
async function createPost(data: {
  title: string
  content: string
  author: string
}) {
  const post = await pb.collection('posts').create({
    title: data.title,
    content: data.content,
    author: data.author,
    slug: slugify(data.title),
    published: false
  })

  return post
}

async function createPostWithCover(
  data: {
    title: string
    content: string
    author: string
  },
  coverFile: File
) {
  const formData = new FormData()
  formData.append('title', data.title)
  formData.append('content', data.content)
  formData.append('author', data.author)
  formData.append('cover', coverFile)

  const post = await pb.collection('posts').create(formData)
  return post
}

Read

Code
TypeScript
async function getPostById(id: string) {
  const post = await pb.collection('posts').getOne(id, {
    expand: 'author,categories'
  })
  return post
}

async function getPostBySlug(slug: string) {
  const post = await pb.collection('posts').getFirstListItem(
    `slug = "${slug}"`,
    { expand: 'author,categories' }
  )
  return post
}

async function getPosts(page = 1, perPage = 20) {
  const posts = await pb.collection('posts').getList(page, perPage, {
    filter: 'published = true',
    sort: '-created',
    expand: 'author,categories'
  })

  return {
    items: posts.items,
    page: posts.page,
    perPage: posts.perPage,
    totalItems: posts.totalItems,
    totalPages: posts.totalPages
  }
}

async function getAllPosts() {
  const posts = await pb.collection('posts').getFullList({
    filter: 'published = true',
    sort: '-created',
    expand: 'author'
  })
  return posts
}

async function getAllPostSlugs() {
  const posts = await pb.collection('posts').getFullList({
    fields: 'slug'
  })
  return posts.map(p => p.slug)
}

Update

Code
TypeScript
async function updatePost(id: string, data: Partial<Post>) {
  const post = await pb.collection('posts').update(id, data)
  return post
}

async function updatePostCover(id: string, coverFile: File) {
  const formData = new FormData()
  formData.append('cover', coverFile)

  const post = await pb.collection('posts').update(id, formData)
  return post
}

async function removePostCover(id: string) {
  const post = await pb.collection('posts').update(id, {
    cover: null
  })
  return post
}

Delete

Code
TypeScript
async function deletePost(id: string) {
  await pb.collection('posts').delete(id)
}

async function deleteMultiplePosts(ids: string[]) {
  await Promise.all(
    ids.map(id => pb.collection('posts').delete(id))
  )
}

Filters and sorting

Filter syntax

Code
TypeScript
await pb.collection('posts').getList(1, 20, {
  filter: 'published = true'
})

await pb.collection('products').getList(1, 20, {
  filter: 'price >= 100 && price <= 500'
})

await pb.collection('posts').getList(1, 20, {
  filter: 'title ~ "JavaScript"'
})

await pb.collection('posts').getList(1, 20, {
  filter: 'title ~ "%react%"'
})

await pb.collection('posts').getList(1, 20, {
  filter: 'cover != ""'
})

await pb.collection('posts').getList(1, 20, {
  filter: 'created >= "2024-01-01 00:00:00"'
})

await pb.collection('posts').getList(1, 20, {
  filter: 'categories ?~ "abc123"'
})

await pb.collection('posts').getList(1, 20, {
  filter: `
    published = true &&
    (title ~ "${searchTerm}" || content ~ "${searchTerm}") &&
    author = "${authorId}"
  `
})

Sorting

Code
TypeScript
await pb.collection('posts').getList(1, 20, {
  sort: 'created'
})

await pb.collection('posts').getList(1, 20, {
  sort: '-created'
})

await pb.collection('posts').getList(1, 20, {
  sort: '-published,created'
})

await pb.collection('posts').getList(1, 20, {
  sort: 'author.name'
})

await pb.collection('posts').getList(1, 20, {
  sort: '@random'
})

Field selection and expand

Code
TypeScript
await pb.collection('posts').getList(1, 20, {
  fields: 'id,title,slug,created'
})

await pb.collection('posts').getList(1, 20, {
  expand: 'author,categories'
})

const posts = await pb.collection('posts').getList(1, 20, {
  expand: 'author'
})

posts.items.forEach(post => {
  console.log('Author name:', post.expand?.author?.name)
})

await pb.collection('comments').getList(1, 20, {
  expand: 'post.author'
})

await pb.collection('users').getList(1, 20, {
  expand: 'posts_via_author'
})

Realtime subscriptions

Subscribing to a collection

Code
TypeScript
pb.collection('posts').subscribe('*', (e) => {
  console.log('Action:', e.action)
  console.log('Record:', e.record)

  switch (e.action) {
    case 'create':
      addPostToUI(e.record)
      break
    case 'update':
      updatePostInUI(e.record)
      break
    case 'delete':
      removePostFromUI(e.record.id)
      break
  }
})

pb.collection('posts').subscribe('RECORD_ID', (e) => {
  console.log('Post updated:', e.record)
})

pb.collection('posts').subscribe('*', (e) => {
  if (e.record.published) {
    handlePublishedPost(e.record)
  }
})

Managing subscriptions

Code
TypeScript
pb.collection('posts').unsubscribe('*')
pb.collection('posts').unsubscribe('RECORD_ID')

pb.realtime.unsubscribe()

function useRealtimePosts() {
  const [posts, setPosts] = useState<Post[]>([])
  const pb = usePocketBase()

  useEffect(() => {
    pb.collection('posts').getFullList({
      filter: 'published = true',
      sort: '-created'
    }).then(setPosts)

    pb.collection('posts').subscribe('*', (e) => {
      setPosts(current => {
        switch (e.action) {
          case 'create':
            if (e.record.published) {
              return [e.record as Post, ...current]
            }
            return current
          case 'update':
            return current.map(p =>
              p.id === e.record.id ? e.record as Post : p
            )
          case 'delete':
            return current.filter(p => p.id !== e.record.id)
          default:
            return current
        }
      })
    })

    return () => {
      pb.collection('posts').unsubscribe('*')
    }
  }, [pb])

  return posts
}

Realtime for authenticated users

Code
TypeScript
// Subscriptions only work for records the user has access to
// (according to the API rules)

pb.collection('messages').subscribe('*', (e) => {
  // You will only receive messages where:
  // sender = @request.auth.id || receiver = @request.auth.id
  console.log('New message:', e.record)
})

File storage

Uploading files

Code
TypeScript
async function uploadAvatar(userId: string, file: File) {
  const formData = new FormData()
  formData.append('avatar', file)

  const user = await pb.collection('users').update(userId, formData)
  return user
}

async function uploadGalleryImages(postId: string, files: FileList) {
  const formData = new FormData()

  for (let i = 0; i < files.length; i++) {
    formData.append('gallery', files[i])
  }

  const post = await pb.collection('posts').update(postId, formData)
  return post
}

async function uploadWithProgress(
  collectionName: string,
  recordId: string,
  fieldName: string,
  file: File,
  onProgress: (percent: number) => void
) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest()
    const formData = new FormData()
    formData.append(fieldName, file)

    xhr.upload.addEventListener('progress', (e) => {
      if (e.lengthComputable) {
        onProgress(Math.round((e.loaded / e.total) * 100))
      }
    })

    xhr.addEventListener('load', () => {
      if (xhr.status >= 200 && xhr.status < 300) {
        resolve(JSON.parse(xhr.responseText))
      } else {
        reject(new Error(xhr.statusText))
      }
    })

    xhr.addEventListener('error', () => reject(new Error('Upload failed')))

    xhr.open('PATCH', `${pb.baseUrl}/api/collections/${collectionName}/records/${recordId}`)
    xhr.setRequestHeader('Authorization', pb.authStore.token)
    xhr.send(formData)
  })
}

Getting file URLs

Code
TypeScript
const avatarUrl = pb.files.getUrl(user, user.avatar)

const thumbUrl = pb.files.getUrl(user, user.avatar, {
  thumb: '100x100'
})

// Available thumb options:
// - '100x100' - resize to exactly 100x100
// - '100x0' - resize preserving aspect ratio (width 100)
// - '0x100' - resize preserving aspect ratio (height 100)
// - '100x100t' - crop to center
// - '100x100b' - crop from bottom
// - '100x100f' - fit (fit within 100x100 preserving aspect ratio)

const galleryUrls = post.gallery.map(filename =>
  pb.files.getUrl(post, filename)
)

function getPocketBaseImageUrl(
  record: RecordModel,
  filename: string,
  thumb?: string
): string {
  if (!filename) return '/placeholder.png'

  const url = pb.files.getUrl(record, filename, { thumb })
  return url
}

<Image
  src={getPocketBaseImageUrl(user, user.avatar, '200x200')}
  alt={user.name}
  width={200}
  height={200}
/>

Deleting files

Code
TypeScript
await pb.collection('users').update(userId, {
  avatar: null
})

await pb.collection('posts').update(postId, {
  'gallery-': 'filename-to-remove.jpg'
})

const formData = new FormData()
formData.append('gallery+', newFile)
await pb.collection('posts').update(postId, formData)

Extending PocketBase in Go

Custom backend

main.go
Go
// main.go
package main

import (
    "log"
    "net/http"

    "github.com/labstack/echo/v5"
    "github.com/pocketbase/pocketbase"
    "github.com/pocketbase/pocketbase/core"
    "github.com/pocketbase/pocketbase/apis"
)

func main() {
    app := pocketbase.New()

    app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
        e.Router.GET("/api/hello", func(c echo.Context) error {
            return c.JSON(http.StatusOK, map[string]string{
                "message": "Hello from PocketBase!",
            })
        })
        return nil
    })

    app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
        e.Router.GET("/api/protected", func(c echo.Context) error {
            authRecord, _ := c.Get(apis.ContextAuthRecordKey).(*models.Record)

            if authRecord == nil {
                return apis.NewForbiddenError("Unauthorized", nil)
            }

            return c.JSON(http.StatusOK, map[string]any{
                "user": authRecord.Email(),
                "message": "Welcome!",
            })
        }, apis.RequireRecordAuth())
        return nil
    })

    if err := app.Start(); err != nil {
        log.Fatal(err)
    }
}

Event hooks

Code
Go
package main

import (
    "log"

    "github.com/pocketbase/pocketbase"
    "github.com/pocketbase/pocketbase/core"
)

func main() {
    app := pocketbase.New()

    app.OnRecordBeforeCreateRequest("posts").Add(func(e *core.RecordCreateEvent) error {
        if authRecord := e.HttpContext.Get(apis.ContextAuthRecordKey); authRecord != nil {
            e.Record.Set("author", authRecord.(*models.Record).Id)
        }

        title := e.Record.GetString("title")
        slug := slugify(title)
        e.Record.Set("slug", slug)

        return nil
    })

    app.OnRecordAfterCreateRequest("posts").Add(func(e *core.RecordCreateEvent) error {
        log.Printf("New post created: %s", e.Record.GetString("title"))

        // sendEmailToAdmins(e.Record)

        return nil
    })

    app.OnRecordBeforeUpdateRequest("posts").Add(func(e *core.RecordUpdateEvent) error {
        authRecord := e.HttpContext.Get(apis.ContextAuthRecordKey).(*models.Record)

        if e.Record.GetString("author") != authRecord.Id {
            if authRecord.GetString("role") != "admin" {
                return apis.NewForbiddenError("You can only edit your own posts", nil)
            }
        }

        return nil
    })

    app.OnRecordAuthRequest("users").Add(func(e *core.RecordAuthEvent) error {
        log.Printf("User logged in: %s", e.Record.Email())
        return nil
    })

    if err := app.Start(); err != nil {
        log.Fatal(err)
    }
}

Scheduled tasks (cron)

Code
Go
package main

import (
    "github.com/pocketbase/pocketbase"
    "github.com/pocketbase/pocketbase/tools/cron"
)

func main() {
    app := pocketbase.New()

    app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
        scheduler := cron.New()

        scheduler.MustAdd("cleanup", "0 * * * *", func() {
            log.Println("Running cleanup...")
        })

        scheduler.MustAdd("backup", "0 0 * * *", func() {
            log.Println("Running backup...")
            // app.CreateBackup("backup_" + time.Now().Format("2006-01-02"))
        })

        scheduler.MustAdd("publish", "*/5 * * * *", func() {
            publishScheduledPosts(app)
        })

        scheduler.Start()

        return nil
    })

    if err := app.Start(); err != nil {
        log.Fatal(err)
    }
}

func publishScheduledPosts(app *pocketbase.PocketBase) {
    records, err := app.Dao().FindRecordsByFilter(
        "posts",
        "published = false && scheduled_at <= {:now}",
        "-scheduled_at",
        100,
        0,
        dbx.Params{"now": time.Now().UTC().Format(time.RFC3339)},
    )

    if err != nil {
        log.Printf("Error finding scheduled posts: %v", err)
        return
    }

    for _, record := range records {
        record.Set("published", true)
        if err := app.Dao().SaveRecord(record); err != nil {
            log.Printf("Error publishing post %s: %v", record.Id, err)
        }
    }
}

Deployment

Docker

Code
DOCKERFILE
# Dockerfile
FROM alpine:latest

ARG PB_VERSION=0.22.0

RUN apk add --no-cache \
    unzip \
    ca-certificates

# Download 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/

# Create data directory
RUN mkdir -p /pb/pb_data

EXPOSE 8090

# Start PocketBase
CMD ["/pb/pocketbase", "serve", "--http=0.0.0.0:8090"]
docker-compose.yml
YAML
# docker-compose.yml
version: '3.8'

services:
  pocketbase:
    build: .
    container_name: pocketbase
    restart: unless-stopped
    ports:
      - "8090:8090"
    volumes:
      - pocketbase_data:/pb/pb_data
      - ./pb_migrations:/pb/pb_migrations
    healthcheck:
      test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8090/api/health"]
      interval: 30s
      timeout: 10s
      retries: 3

volumes:
  pocketbase_data:

Railway

railway.toml
TOML
# railway.toml
[build]
builder = "dockerfile"

[deploy]
startCommand = "/pb/pocketbase serve --http=0.0.0.0:$PORT"
healthcheckPath = "/api/health"
healthcheckTimeout = 100
restartPolicyType = "ON_FAILURE"
restartPolicyMaxRetries = 10

Fly.io

fly.toml
TOML
# fly.toml
app = "my-pocketbase-app"
primary_region = "waw"

[build]
  dockerfile = "Dockerfile"

[http_service]
  internal_port = 8090
  force_https = true
  auto_stop_machines = false
  auto_start_machines = true
  min_machines_running = 1

[mounts]
  source = "pb_data"
  destination = "/pb/pb_data"

[[vm]]
  cpu_kind = "shared"
  cpus = 1
  memory_mb = 256
Code
Bash
# Deploy to Fly.io
fly launch
fly volumes create pb_data --region waw --size 1
fly deploy

VPS (systemd)

/etc/systemd/system/pocketbase.service
Bash
# /etc/systemd/system/pocketbase.service
[Unit]
Description=PocketBase
After=network.target

[Service]
Type=simple
User=pocketbase
Group=pocketbase
LimitNOFILE=4096
Restart=always
RestartSec=5s
WorkingDirectory=/home/pocketbase
ExecStart=/home/pocketbase/pocketbase serve --http="0.0.0.0:8090"

[Install]
WantedBy=multi-user.target
Code
Bash
# Installation
sudo useradd -r -s /bin/false pocketbase
sudo mkdir -p /home/pocketbase
sudo chown pocketbase:pocketbase /home/pocketbase

# Copy pocketbase to /home/pocketbase/

# Start
sudo systemctl enable pocketbase
sudo systemctl start pocketbase
sudo systemctl status pocketbase

Nginx reverse proxy

Code
NGINX
# /etc/nginx/sites-available/pocketbase
server {
    listen 80;
    server_name api.example.com;

    # Redirect HTTP to HTTPS
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    server_name api.example.com;

    ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;

    # Security headers
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;

    # Max upload size
    client_max_body_size 50M;

    location / {
        proxy_pass http://127.0.0.1:8090;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # WebSocket support
        proxy_read_timeout 86400;
    }
}

Backups and migrations

Automatic backups

Code
Go
package main

import (
    "github.com/pocketbase/pocketbase"
    "github.com/pocketbase/pocketbase/tools/cron"
)

func main() {
    app := pocketbase.New()

    app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
        scheduler := cron.New()

        scheduler.MustAdd("backup", "0 3 * * *", func() {
            name := fmt.Sprintf("backup_%s.zip", time.Now().Format("2006-01-02"))

            if err := app.CreateBackup(name); err != nil {
                log.Printf("Backup failed: %v", err)
                return
            }

            log.Printf("Backup created: %s", name)

            // uploadToS3(name)
        })

        scheduler.Start()
        return nil
    })

    app.Start()
}

Manual backups

Code
Bash
# Backup via Admin UI
# Settings > Backups > Create backup

# Backup via command line
./pocketbase --dir=/path/to/pb_data backup

# Copy directories
cp -r pb_data pb_data_backup_$(date +%Y%m%d)

# Restore
./pocketbase --dir=/path/to/pb_data restore /path/to/backup.zip

Migrations

pb_migrations/1234567890_create_posts.go
Go
// pb_migrations/1234567890_create_posts.go
package migrations

import (
    "github.com/pocketbase/dbx"
    "github.com/pocketbase/pocketbase/daos"
    m "github.com/pocketbase/pocketbase/migrations"
    "github.com/pocketbase/pocketbase/models"
    "github.com/pocketbase/pocketbase/models/schema"
)

func init() {
    m.Register(func(db dbx.Builder) error {
        collection := &models.Collection{}
        collection.Name = "posts"
        collection.Type = models.CollectionTypeBase
        collection.Schema = schema.NewSchema(
            &schema.SchemaField{
                Name:     "title",
                Type:     schema.FieldTypeText,
                Required: true,
            },
            &schema.SchemaField{
                Name: "content",
                Type: schema.FieldTypeEditor,
            },
            &schema.SchemaField{
                Name: "published",
                Type: schema.FieldTypeBool,
            },
        )

        return daos.New(db).SaveCollection(collection)
    }, func(db dbx.Builder) error {
        collection, err := daos.New(db).FindCollectionByNameOrId("posts")
        if err != nil {
            return err
        }
        return daos.New(db).DeleteCollection(collection)
    })
}
Code
Bash
# Generate migrations from Admin UI changes
./pocketbase migrate collections

# Apply migrations
./pocketbase migrate up

# Rollback
./pocketbase migrate down

Framework integrations

Next.js App Router

TSapp/posts/page.tsx
TypeScript
// app/posts/page.tsx
import { getPocketBase } from '@/lib/pocketbase'

export const revalidate = 60

export default async function PostsPage() {
  const pb = getPocketBase()

  const posts = await pb.collection('posts').getList(1, 10, {
    filter: 'published = true',
    sort: '-created',
    expand: 'author'
  })

  return (
    <div>
      <h1>Blog Posts</h1>
      <ul>
        {posts.items.map(post => (
          <li key={post.id}>
            <a href={`/posts/${post.slug}`}>{post.title}</a>
            <span>by {post.expand?.author?.name}</span>
          </li>
        ))}
      </ul>
    </div>
  )
}
TSapp/posts/[slug]/page.tsx
TypeScript
// app/posts/[slug]/page.tsx
import { getPocketBase } from '@/lib/pocketbase'
import { notFound } from 'next/navigation'

export async function generateStaticParams() {
  const pb = getPocketBase()
  const posts = await pb.collection('posts').getFullList({
    fields: 'slug'
  })

  return posts.map(post => ({
    slug: post.slug
  }))
}

export default async function PostPage({
  params
}: {
  params: { slug: string }
}) {
  const pb = getPocketBase()

  try {
    const post = await pb.collection('posts').getFirstListItem(
      `slug = "${params.slug}" && published = true`,
      { expand: 'author,categories' }
    )

    return (
      <article>
        <h1>{post.title}</h1>
        <div dangerouslySetInnerHTML={{ __html: post.content }} />
      </article>
    )
  } catch {
    notFound()
  }
}

SvelteKit

TSsrc/lib/pocketbase.ts
TypeScript
// src/lib/pocketbase.ts
import PocketBase from 'pocketbase'
import { writable } from 'svelte/store'

export const pb = new PocketBase(import.meta.env.VITE_POCKETBASE_URL)

export const currentUser = writable(pb.authStore.model)

pb.authStore.onChange((auth) => {
  currentUser.set(pb.authStore.model)
})
src/routes/posts/+page.svelte
SVELTE
<!-- src/routes/posts/+page.svelte -->
<script lang="ts">
  import { pb } from '$lib/pocketbase'
  import { onMount } from 'svelte'

  let posts = []

  onMount(async () => {
    posts = await pb.collection('posts').getFullList({
      filter: 'published = true',
      sort: '-created'
    })
  })
</script>

<h1>Posts</h1>
{#each posts as post}
  <article>
    <h2><a href="/posts/{post.slug}">{post.title}</a></h2>
  </article>
{/each}

React Native / Expo

TSlib/pocketbase.ts
TypeScript
// lib/pocketbase.ts
import PocketBase from 'pocketbase'
import AsyncStorage from '@react-native-async-storage/async-storage'

const pb = new PocketBase('https://api.example.com')

pb.authStore.onChange(async () => {
  try {
    if (pb.authStore.isValid) {
      await AsyncStorage.setItem('pb_auth', JSON.stringify({
        token: pb.authStore.token,
        model: pb.authStore.model
      }))
    } else {
      await AsyncStorage.removeItem('pb_auth')
    }
  } catch (e) {
    console.error('Error saving auth state:', e)
  }
})

export async function initPocketBase() {
  try {
    const authData = await AsyncStorage.getItem('pb_auth')
    if (authData) {
      const { token, model } = JSON.parse(authData)
      pb.authStore.save(token, model)
    }
  } catch (e) {
    console.error('Error restoring auth state:', e)
  }
  return pb
}

export default pb

Best practices

Project structure

Code
TEXT
my-app/
├── pocketbase/
│   ├── pocketbase           # Binary
│   ├── pb_data/            # Data directory
│   ├── pb_migrations/      # Migrations
│   └── main.go             # Custom backend (optional)
├── frontend/
│   ├── src/
│   │   ├── lib/
│   │   │   └── pocketbase.ts
│   │   ├── types/
│   │   │   └── pocketbase.ts
│   │   └── hooks/
│   │       └── usePocketBase.ts
│   └── ...
└── docker-compose.yml

Security

Code
TypeScript
function validatePostData(data: unknown): data is CreatePostInput {
  if (typeof data !== 'object' || data === null) return false

  const { title, content } = data as Record<string, unknown>

  return (
    typeof title === 'string' &&
    title.length > 0 &&
    title.length <= 200 &&
    typeof content === 'string'
  )
}

import DOMPurify from 'dompurify'

function sanitizeContent(html: string): string {
  return DOMPurify.sanitize(html, {
    ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'a', 'h2', 'h3'],
    ALLOWED_ATTR: ['href', 'target', 'rel']
  })
}

const posts = await pb.collection('posts').getList(1, 20, {
  filter: `author = "${sanitizedUserId}"`
})

Error handling

Code
TypeScript
import PocketBase, { ClientResponseError } from 'pocketbase'

async function safePocketBaseCall<T>(
  operation: () => Promise<T>
): Promise<{ data: T | null; error: string | null }> {
  try {
    const data = await operation()
    return { data, error: null }
  } catch (err) {
    if (err instanceof ClientResponseError) {
      console.error('PocketBase error:', err.status, err.message)

      switch (err.status) {
        case 400:
          return { data: null, error: 'Invalid request data' }
        case 401:
          return { data: null, error: 'Please login to continue' }
        case 403:
          return { data: null, error: 'You don\'t have permission' }
        case 404:
          return { data: null, error: 'Resource not found' }
        default:
          return { data: null, error: 'Something went wrong' }
      }
    }

    console.error('Unknown error:', err)
    return { data: null, error: 'An unexpected error occurred' }
  }
}

const { data: posts, error } = await safePocketBaseCall(() =>
  pb.collection('posts').getList(1, 20)
)

if (error) {
  showToast(error)
} else {
  displayPosts(posts)
}

Performance optimization

Code
TypeScript
const posts = await pb.collection('posts').getList(1, 20, {
  fields: 'id,title,slug,excerpt,created'
})

const post = await pb.collection('posts').getOne(id, {
  expand: 'author,categories'
})

const CACHE_TTL = 5 * 60 * 1000

const cache = new Map<string, { data: any; timestamp: number }>()

async function getCachedPosts() {
  const cacheKey = 'posts_list'
  const cached = cache.get(cacheKey)

  if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
    return cached.data
  }

  const posts = await pb.collection('posts').getList(1, 20)
  cache.set(cacheKey, { data: posts, timestamp: Date.now() })

  return posts
}

async function loadMorePosts(page: number) {
  const posts = await pb.collection('posts').getList(page, 20, {
    filter: 'published = true',
    sort: '-created'
  })

  return {
    items: posts.items,
    hasMore: posts.page < posts.totalPages
  }
}

FAQ - frequently asked questions

Is PocketBase production-ready?

Yes, PocketBase is used in production by many applications. Keep these limitations in mind:

  • SQLite has limits for very heavy traffic (>1000 write req/s)
  • No horizontal scaling (single instance)
  • Suitable for small to medium applications (up to ~100K users)

How do I back up PocketBase?

Code
Bash
# 1. Via Admin UI: Settings > Backups
# 2. Via CLI: ./pocketbase backup
# 3. Manually: cp -r pb_data/ backup/

Can I use PostgreSQL instead of SQLite?

No, PocketBase uses SQLite exclusively. If you need PostgreSQL, consider Supabase or a custom backend with Prisma.

How do I import data into PocketBase?

Code
TypeScript
async function importData(data: any[]) {
  for (const item of data) {
    await pb.collection('posts').create(item)
  }
}

// Or via the Admin API
// Settings > Collections > Import

How do I configure CORS?

PocketBase has built-in CORS support. By default it accepts all origins. You can change this in a custom backend:

Code
Go
app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
    e.Router.Use(middleware.CORSWithConfig(middleware.CORSConfig{
        AllowOrigins: []string{"https://example.com"},
        AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete},
    }))
    return nil
})

How do I send emails from PocketBase?

Configure SMTP in Admin Panel > Settings > Mail settings:

Code
TEXT
SMTP Server: smtp.gmail.com
Port: 587
Username: your-email@gmail.com
Password: app-specific-password

How do I debug queries?

Code
TypeScript
pb.beforeSend = function (url, options) {
  console.log('Request:', url, options)
  return { url, options }
}

pb.afterSend = function (response, data) {
  console.log('Response:', response.status, data)
  return data
}

Summary

PocketBase is a revolutionary approach to application backends. It offers:

  • Simplicity - A single file instead of dozens of services
  • Performance - SQLite + Go = a fast backend
  • Completeness - Auth, realtime, files, admin panel
  • Flexibility - Extensible in Go
  • Control - Self-hosted, your data

For MVP projects, side projects, and small to medium applications, PocketBase is an ideal choice. You launch a single file and have a working backend in seconds -- no configuration, no infrastructure costs, no vendor lock-in.