PocketBase is an open-source backend written in Go that ships as a single binary file. It provides a SQLite database, built-in authentication, file storage, real-time subscriptions, and an admin dashboard — all without complex configuration. Combined with Next.js, it creates a modern, lightweight fullstack stack ideal for personal projects, MVPs, and medium-sized applications.
In this tutorial, you will build a complete task management app (todo app) with user authentication, real-time CRUD operations, and production deployment.
Prerequisites
Before starting, make sure you have:
- Node.js 18+ installed on your machine
- npm or pnpm as your package manager
- Basic knowledge of React and TypeScript
- A code editor (VS Code recommended)
- A terminal (bash, zsh, or PowerShell)
What You Will Build
A task management application with the following features:
- User registration and login
- Create, read, update, and delete tasks
- Real-time updates via PocketBase subscriptions
- Responsive interface with Tailwind CSS
- Production-ready deployment
Step 1: Install PocketBase
PocketBase is distributed as a single executable file. Download it from the official website.
# Create a folder for the backend
mkdir pocketbase-backend && cd pocketbase-backend
# Download PocketBase (Linux/macOS)
# Visit https://pocketbase.io/docs/ for the latest version
wget https://github.com/pocketbase/pocketbase/releases/download/v0.25.0/pocketbase_0.25.0_linux_amd64.zip
# Or on macOS with Homebrew
brew install pocketbase
# Extract
unzip pocketbase_0.25.0_linux_amd64.zipStart PocketBase:
./pocketbase serveYou will see in the terminal:
> Server started at: http://127.0.0.1:8090
> - REST API: http://127.0.0.1:8090/api/
> - Admin UI: http://127.0.0.1:8090/_/
Open http://127.0.0.1:8090/_/ in your browser to access the admin dashboard. On first access, create an admin account.
Step 2: Configure PocketBase Collections
In the admin dashboard, create a tasks collection with the following fields:
| Field | Type | Options |
|---|---|---|
title | Text | Required, max 200 characters |
description | Text | Optional |
completed | Bool | Default value: false |
user | Relation | Collection: users, required |
Configure Access Rules
In the API Rules tab of the tasks collection:
- List/Search:
@request.auth.id != "" && user = @request.auth.id - View:
@request.auth.id != "" && user = @request.auth.id - Create:
@request.auth.id != "" - Update:
@request.auth.id != "" && user = @request.auth.id - Delete:
@request.auth.id != "" && user = @request.auth.id
These rules ensure that each user can only view and modify their own tasks.
Step 3: Create the Next.js Project
Open a new terminal and create the frontend project:
npx create-next-app@latest pocketbase-todo --typescript --tailwind --app --src-dir --use-npm
cd pocketbase-todoInstall the PocketBase SDK:
npm install pocketbaseStep 4: Configure the PocketBase Client
Create the PocketBase client configuration file:
// src/lib/pocketbase.ts
import PocketBase from "pocketbase";
const pb = new PocketBase("http://127.0.0.1:8090");
// Disable auto-cancellation to avoid conflicts with React
pb.autoCancellation(false);
export default pb;Create TypeScript types for your data:
// src/types/index.ts
export interface Task {
id: string;
title: string;
description: string;
completed: boolean;
user: string;
created: string;
updated: string;
}
export interface User {
id: string;
email: string;
name: string;
avatar: string;
}Step 5: Create the Authentication Context
Create a React provider to manage authentication state globally:
// src/contexts/AuthContext.tsx
"use client";
import {
createContext,
useContext,
useEffect,
useState,
useCallback,
type ReactNode,
} from "react";
import pb from "@/lib/pocketbase";
import type { User } from "@/types";
interface AuthContextType {
user: User | null;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
register: (email: string, password: string, name: string) => Promise<void>;
logout: () => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Check if user is already logged in
if (pb.authStore.isValid) {
const model = pb.authStore.model;
if (model) {
setUser({
id: model.id,
email: model.email,
name: model.name || "",
avatar: model.avatar || "",
});
}
}
setIsLoading(false);
// Listen for auth changes
const unsubscribe = pb.authStore.onChange((_token, model) => {
if (model) {
setUser({
id: model.id,
email: model.email,
name: model.name || "",
avatar: model.avatar || "",
});
} else {
setUser(null);
}
});
return () => unsubscribe();
}, []);
const login = useCallback(async (email: string, password: string) => {
await pb.collection("users").authWithPassword(email, password);
}, []);
const register = useCallback(
async (email: string, password: string, name: string) => {
await pb.collection("users").create({
email,
password,
passwordConfirm: password,
name,
});
// Auto-login after registration
await pb.collection("users").authWithPassword(email, password);
},
[]
);
const logout = useCallback(() => {
pb.authStore.clear();
setUser(null);
}, []);
return (
<AuthContext.Provider value={{ user, isLoading, login, register, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}Step 6: Create the Task Management Hook
This custom hook encapsulates all CRUD logic and real-time subscriptions:
// src/hooks/useTasks.ts
"use client";
import { useState, useEffect, useCallback } from "react";
import pb from "@/lib/pocketbase";
import type { Task } from "@/types";
import { useAuth } from "@/contexts/AuthContext";
export function useTasks() {
const [tasks, setTasks] = useState<Task[]>([]);
const [isLoading, setIsLoading] = useState(true);
const { user } = useAuth();
// Fetch tasks
const fetchTasks = useCallback(async () => {
if (!user) return;
try {
setIsLoading(true);
const records = await pb.collection("tasks").getFullList<Task>({
sort: "-created",
filter: `user = "${user.id}"`,
});
setTasks(records);
} catch (error) {
console.error("Error loading tasks:", error);
} finally {
setIsLoading(false);
}
}, [user]);
// Real-time subscription
useEffect(() => {
if (!user) return;
fetchTasks();
// Subscribe to collection changes
pb.collection("tasks").subscribe<Task>("*", (event) => {
switch (event.action) {
case "create":
setTasks((prev) => [event.record, ...prev]);
break;
case "update":
setTasks((prev) =>
prev.map((task) =>
task.id === event.record.id ? event.record : task
)
);
break;
case "delete":
setTasks((prev) =>
prev.filter((task) => task.id !== event.record.id)
);
break;
}
});
return () => {
pb.collection("tasks").unsubscribe("*");
};
}, [user, fetchTasks]);
// Create a task
const createTask = useCallback(
async (title: string, description: string = "") => {
if (!user) return;
await pb.collection("tasks").create({
title,
description,
completed: false,
user: user.id,
});
},
[user]
);
// Toggle task status
const toggleTask = useCallback(async (task: Task) => {
await pb.collection("tasks").update(task.id, {
completed: !task.completed,
});
}, []);
// Delete a task
const deleteTask = useCallback(async (taskId: string) => {
await pb.collection("tasks").delete(taskId);
}, []);
// Update a task
const updateTask = useCallback(
async (taskId: string, data: Partial<Task>) => {
await pb.collection("tasks").update(taskId, data);
},
[]
);
return {
tasks,
isLoading,
createTask,
toggleTask,
deleteTask,
updateTask,
refetch: fetchTasks,
};
}Step 7: Build the UI Components
Login/Register Form
// src/components/AuthForm.tsx
"use client";
import { useState, type FormEvent } from "react";
import { useAuth } from "@/contexts/AuthContext";
export default function AuthForm() {
const [isLogin, setIsLogin] = useState(true);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [name, setName] = useState("");
const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const { login, register } = useAuth();
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError("");
setIsSubmitting(true);
try {
if (isLogin) {
await login(email, password);
} else {
await register(email, password, name);
}
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : "An error occurred";
setError(message);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="mx-auto max-w-md rounded-xl bg-white p-8 shadow-lg">
<h2 className="mb-6 text-center text-2xl font-bold text-gray-800">
{isLogin ? "Sign In" : "Create Account"}
</h2>
{error && (
<div className="mb-4 rounded-lg bg-red-50 p-3 text-sm text-red-600">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
{!isLogin && (
<input
type="text"
placeholder="Your name"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-lg border px-4 py-3 focus:border-blue-500 focus:outline-none"
required
/>
)}
<input
type="email"
placeholder="Email address"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded-lg border px-4 py-3 focus:border-blue-500 focus:outline-none"
required
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-lg border px-4 py-3 focus:border-blue-500 focus:outline-none"
required
minLength={8}
/>
<button
type="submit"
disabled={isSubmitting}
className="w-full rounded-lg bg-blue-600 py-3 font-medium text-white transition hover:bg-blue-700 disabled:opacity-50"
>
{isSubmitting
? "Loading..."
: isLogin
? "Sign In"
: "Create Account"}
</button>
</form>
<p className="mt-4 text-center text-sm text-gray-600">
{isLogin ? "No account yet?" : "Already have an account?"}
<button
onClick={() => setIsLogin(!isLogin)}
className="ml-1 font-medium text-blue-600 hover:underline"
>
{isLogin ? "Sign Up" : "Sign In"}
</button>
</p>
</div>
);
}Task List Component
// src/components/TaskList.tsx
"use client";
import { useState, type FormEvent } from "react";
import { useTasks } from "@/hooks/useTasks";
import { useAuth } from "@/contexts/AuthContext";
import type { Task } from "@/types";
function TaskItem({
task,
onToggle,
onDelete,
}: {
task: Task;
onToggle: (task: Task) => void;
onDelete: (id: string) => void;
}) {
return (
<div className="group flex items-center gap-3 rounded-lg border bg-white p-4 transition hover:shadow-md">
<button
onClick={() => onToggle(task)}
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full border-2 transition ${
task.completed
? "border-green-500 bg-green-500 text-white"
: "border-gray-300 hover:border-blue-400"
}`}
>
{task.completed && (
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
)}
</button>
<div className="flex-1">
<h3
className={`font-medium ${
task.completed ? "text-gray-400 line-through" : "text-gray-800"
}`}
>
{task.title}
</h3>
{task.description && (
<p className="mt-1 text-sm text-gray-500">{task.description}</p>
)}
</div>
<button
onClick={() => onDelete(task.id)}
className="rounded-lg p-2 text-gray-400 opacity-0 transition hover:bg-red-50 hover:text-red-500 group-hover:opacity-100"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
);
}
export default function TaskList() {
const [newTitle, setNewTitle] = useState("");
const [newDescription, setNewDescription] = useState("");
const { tasks, isLoading, createTask, toggleTask, deleteTask } = useTasks();
const { user, logout } = useAuth();
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!newTitle.trim()) return;
await createTask(newTitle.trim(), newDescription.trim());
setNewTitle("");
setNewDescription("");
};
const completedCount = tasks.filter((t) => t.completed).length;
return (
<div className="mx-auto max-w-2xl">
{/* Header */}
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-800">My Tasks</h1>
<p className="mt-1 text-gray-500">
Hello, {user?.name || user?.email}
</p>
</div>
<button
onClick={logout}
className="rounded-lg px-4 py-2 text-sm text-gray-600 transition hover:bg-gray-100"
>
Sign Out
</button>
</div>
{/* Add Task Form */}
<form onSubmit={handleSubmit} className="mb-6 space-y-3">
<input
type="text"
placeholder="Add a new task..."
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
className="w-full rounded-xl border-2 border-gray-200 px-4 py-3 text-lg focus:border-blue-500 focus:outline-none"
/>
<div className="flex gap-3">
<input
type="text"
placeholder="Description (optional)"
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
className="flex-1 rounded-lg border px-4 py-2 focus:border-blue-500 focus:outline-none"
/>
<button
type="submit"
disabled={!newTitle.trim()}
className="rounded-lg bg-blue-600 px-6 py-2 font-medium text-white transition hover:bg-blue-700 disabled:opacity-50"
>
Add
</button>
</div>
</form>
{/* Stats */}
<div className="mb-4 flex gap-4 text-sm text-gray-500">
<span>{tasks.length} total task(s)</span>
<span>{completedCount} completed</span>
<span>{tasks.length - completedCount} in progress</span>
</div>
{/* Task List */}
{isLoading ? (
<div className="py-12 text-center text-gray-400">
Loading tasks...
</div>
) : tasks.length === 0 ? (
<div className="py-12 text-center text-gray-400">
<p className="text-lg">No tasks yet</p>
<p className="mt-2 text-sm">Create your first task above</p>
</div>
) : (
<div className="space-y-2">
{tasks.map((task) => (
<TaskItem
key={task.id}
task={task}
onToggle={toggleTask}
onDelete={deleteTask}
/>
))}
</div>
)}
</div>
);
}Step 8: Assemble the Main Page
Integrate the auth provider in the layout:
// src/app/layout.tsx
import type { Metadata } from "next";
import { AuthProvider } from "@/contexts/AuthContext";
import "./globals.css";
export const metadata: Metadata = {
title: "Todo App - PocketBase + Next.js",
description: "Task management application with PocketBase and Next.js",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<AuthProvider>{children}</AuthProvider>
</body>
</html>
);
}Create the main page that displays either the auth form or the task list:
// src/app/page.tsx
"use client";
import AuthForm from "@/components/AuthForm";
import TaskList from "@/components/TaskList";
import { useAuth } from "@/contexts/AuthContext";
export default function Home() {
const { user, isLoading } = useAuth();
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-500 border-t-transparent" />
</div>
);
}
return (
<main className="min-h-screen bg-gray-50 px-4 py-12">
{user ? <TaskList /> : <AuthForm />}
</main>
);
}Step 9: Environment Variables
Create a .env.local file to configure the PocketBase URL:
NEXT_PUBLIC_POCKETBASE_URL=http://127.0.0.1:8090Update the PocketBase client to use this variable:
// src/lib/pocketbase.ts
import PocketBase from "pocketbase";
const pb = new PocketBase(
process.env.NEXT_PUBLIC_POCKETBASE_URL || "http://127.0.0.1:8090"
);
pb.autoCancellation(false);
export default pb;Step 10: Run the Application
Open two terminals:
# Terminal 1: PocketBase
cd pocketbase-backend
./pocketbase serve
# Terminal 2: Next.js
cd pocketbase-todo
npm run devOpen http://localhost:3000 in your browser. You should see the login form. Create an account, then start adding tasks.
Advanced Features
Filtering and Search
Add a filtering system to your task list:
// In useTasks.ts, add a search function
const searchTasks = useCallback(
async (query: string) => {
if (!user) return;
const records = await pb.collection("tasks").getFullList<Task>({
sort: "-created",
filter: `user = "${user.id}" && title ~ "${query}"`,
});
setTasks(records);
},
[user]
);Pagination
For applications with lots of data, use pagination:
const fetchTasksPaginated = useCallback(
async (page: number = 1, perPage: number = 20) => {
if (!user) return;
const result = await pb.collection("tasks").getList<Task>(page, perPage, {
sort: "-created",
filter: `user = "${user.id}"`,
});
return {
items: result.items,
totalPages: result.totalPages,
totalItems: result.totalItems,
};
},
[user]
);File Uploads
PocketBase handles files natively. Here is how to add attachments to tasks:
const createTaskWithFile = useCallback(
async (title: string, file: File) => {
if (!user) return;
const formData = new FormData();
formData.append("title", title);
formData.append("user", user.id);
formData.append("completed", "false");
formData.append("attachment", file);
await pb.collection("tasks").create(formData);
},
[user]
);Production Deployment
Deploy PocketBase
PocketBase can be deployed on any Linux server:
# On the server
mkdir -p /opt/pocketbase
cd /opt/pocketbase
# Download and extract PocketBase
wget https://github.com/pocketbase/pocketbase/releases/download/v0.25.0/pocketbase_0.25.0_linux_amd64.zip
unzip pocketbase_0.25.0_linux_amd64.zip
# Create a systemd service
sudo tee /etc/systemd/system/pocketbase.service > /dev/null << 'EOF'
[Unit]
Description=PocketBase
After=network.target
[Service]
Type=simple
User=root
ExecStart=/opt/pocketbase/pocketbase serve --http="0.0.0.0:8090"
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=multi-user.target
EOF
# Enable and start the service
sudo systemctl enable pocketbase
sudo systemctl start pocketbaseDeploy Next.js
Deploy the frontend on Vercel, Netlify, or your own server:
# Update the PocketBase URL for production
# .env.production
NEXT_PUBLIC_POCKETBASE_URL=https://api.your-domain.com
# Production build
npm run build
npm startNginx Configuration (reverse proxy)
server {
listen 80;
server_name api.your-domain.com;
location / {
proxy_pass http://127.0.0.1:8090;
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 for real-time
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}Troubleshooting
CORS Errors
If you encounter CORS errors, start PocketBase with the origins option:
./pocketbase serve --origins="http://localhost:3000,https://your-domain.com"WebSocket Connection Errors
Make sure your reverse proxy is configured to support WebSockets (see the Nginx config above with Upgrade and Connection headers).
Data Not Updating in Real-Time
Check that:
- The PocketBase client is configured with
autoCancellation(false) - The subscription (
subscribe) is properly initialized - Cleanup (
unsubscribe) is done in theuseEffectreturn
Next Steps
Now that your application is working, here are some ideas to go further:
- Add categories: create a "categories" collection and link it to tasks
- Implement drag-and-drop: use
@dnd-kit/coreto reorder tasks - Add notifications: send email reminders via PocketBase hooks
- Offline mode: use a service worker to allow usage without connection
- E2E tests: add Playwright tests to validate user flows
Conclusion
You have built a complete fullstack application with PocketBase and Next.js. PocketBase offers a lightweight and powerful alternative to traditional backends, with features like authentication, real-time updates, and file storage — all in a single binary.
The PocketBase + Next.js combination is particularly well-suited for:
- Rapid prototypes and MVPs
- Personal apps and side projects
- Small to medium production applications
- Developers who want full control over their stack
The complete source code for this tutorial is available for reference and can be adapted to your own projects.