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

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

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.zip

Start PocketBase:

./pocketbase serve

You 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:

FieldTypeOptions
titleTextRequired, max 200 characters
descriptionTextOptional
completedBoolDefault value: false
userRelationCollection: 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-todo

Install the PocketBase SDK:

npm install pocketbase

Step 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:8090

Update 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 dev

Open http://localhost:3000 in your browser. You should see the login form. Create an account, then start adding tasks.

Advanced Features

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 pocketbase

Deploy 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 start

Nginx 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:

  1. The PocketBase client is configured with autoCancellation(false)
  2. The subscription (subscribe) is properly initialized
  3. Cleanup (unsubscribe) is done in the useEffect return

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/core to 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.


Want to read more tutorials? Check out our latest tutorial on 8 Laravel 11 Basics: Views.

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.

30 min read·

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.

25 min read·