Build a Fullstack App with PocketBase and Next.js in 2026

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.
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

Build a Real-Time Full-Stack App with Convex and Next.js 15
Learn how to build a real-time full-stack application using Convex and Next.js 15. This tutorial covers schema design, queries, mutations, real-time subscriptions, authentication, and file uploads — all with end-to-end type safety.

Authenticate Your Next.js 15 App with Auth.js v5: Email, OAuth, and Role-Based Access
Learn how to add production-ready authentication to your Next.js 15 application using Auth.js v5. This comprehensive guide covers Google OAuth, email/password credentials, protected routes, middleware, and role-based access control.

Build a Local AI Chatbot with Ollama and Next.js: Complete Guide
Build a private, fully local AI chatbot using Ollama and Next.js. This hands-on tutorial covers installation, streaming responses, model selection, and deploying a production-ready chat interface — all without sending data to the cloud.