PocketBase is an open source package that developers can leverage to spin up a backend for their SaaS and mobile applications. It’s written in Go, so it’s more performant under heavy loads compared to Node.js.
You can use PocketBase to build full-stack applications quickly — it provides many essential features out of the box, saving a lot of developer time and effort. In this tutorial-style post, let’s take a look at how we can leverage the power of PocketBase to build a forum-like web application.
You can find the code for our demo project here and follow along.
The Replay is a weekly newsletter for dev and engineering leaders.
Delivered once a week, it's your curated guide to the most important conversations around frontend dev, emerging AI tools, and the state of modern software.
PocketBase provides many benefits to developers working on full-stack apps. For example, it:
We’ll see these benefits and more in action as we build our demo app. In our forum application, users will be able to:
Let’s dive into our tutorial.
PocketBase is distributed as an executable for all major operating systems. Setting it up is very easy. Head to the PocketBase docs and download the executable for the OS that you’re on. It will download a zip file, which you must unzip to access the executable:

Run the executable with the following command:
./pocketbase serve
This command will start PocketBase. You should see this printed in the terminal window:

We will explore the Admin UI in the later sections of this post.
We’ll be using GitHub OAuth2 to onboard users onto our forum. To integrate GitHub OAuth2, we first need to create a OAuth application. Head over to GitHub’s Developer Settings (you must be logged in to GitHub) and click on OAuth Apps on the sidebar:

Then, click the New OAuth App button (or the Register a new application button, if you’ve never created an OAuth app before) and fill in the form:

In the Authorization callback URL field, paste in the following:
http://127.0.0.1:8090/api/oauth2-redirect
You can provide any URL you’d like in the Homepage URL field. You should paste your application’s web address if you’re developing a real application that uses GitHub OAuth2.
Once you’ve filled out all the required fields, click on the Register application button. Then, open the application and copy the client ID and client secret. We’ll need these values to enable GitHub as an OAuth2 provider in the PocketBase Admin UI.
Remember that the client secret is only visible once, so make sure to copy it somewhere safe.
Open the PocketBase Admin UI by visiting http://127.0.0.1:8090/_/.
You need to create an admin account when using the Admin UI for the first time. Once you’ve created your admin account, log in with your admin credentials, head over to Settings, and click on Auth providers. From the list, select GitHub, add the Client ID and Client secret, and hit Save:

PocketBase is now configured for GitHub OAuth2.
Now that we have successfully set up PocketBase and our GitHub application, let’s create a React project. We will use Vite to bootstrap our React project. Run this command:
npm create vite@latest forum-pocketbase --template react-ts
Follow the prompts. Once the app has been created, cd into the project directory and run this command:
cd forum-pocketbase npm i
This will install all packages described in package.json. Now, let’s install Chakra UI:
npm i @chakra-ui/react @emotion/react @emotion/styled framer-motion
Followed by installing react-router-dom and its dependencies:
npm install react-router-dom localforage match-sorter sort-by
Once installed, open main.tsx and add the following code:
/* ./src/main.tsx */
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import { ChakraProvider, Flex } from "@chakra-ui/react";
import Join from "./routes/Join"; // We will define this later
import Home from "./routes/Home"; // We will define this later
import PrivateRoute from "./routes/PrivateRoute"; // We will define this later
const router = createBrowserRouter([
{
path: "/join",
element: <Join />,
},
{
path: "/",
element: <PrivateRoute />,
children: [
{
index: true,
element: <Home />,
},
],
},
]);
ReactDOM.createRoot(document.getElementById("root")!).render(
<ChakraProvider>
<Flex flexDirection="column" paddingX="80" paddingY="10" h="100%">
<RouterProvider router={router} />
</Flex>
</ChakraProvider>
);
There are two things to note in the code above.
First, we wrap our application in ChakraProvider component. This is a required step in setting up Chakra UI.
Then, we nest the RouterProvider component from React Router inside the ChakraProvider component. The RouteProvider takes the router as its input and helps with client-side routing.
React Router provides various routers — here, we’re using the browser router. We’ve defined two routes in the router config: a / root route and a /join route. We will define the corresponding components later.
Now that we have set up React and PocketBase, let’s create the necessary tables using the Admin UI. We will add two tables — a Posts table and a Comments table:

The Posts table will store all the posts made by members. The Comments table will store all the comments made on a post. The Users table is created by PocketBase out of the box.
Navigate to http://127.0.0.1:8090/_/ and log in with your admin credentials. Click on the Collections icon in the sidebar and click on the New collection button:

Create two collections: posts and comments.
The posts collection should have these fields:
post_text with its type set as Plain Text; make it non-emptyauthor_id with its type set as Relation; select Single and the Users collection from the dropdown. This relation means only one user can be associated with one postid, created, and updatedThe comments collection should have these fields:
comment_text with its type set as Plain Text; make it non-emptyauthor with its type set as Relation; select Single and the Users collection from the dropdownpost with its type set as Relation; select Single and the Posts collection from the respective dropdownThis is how the two collections should look after the configuration described above. First, the comments collection:

Next, the posts collection:

With the collections all set up, let’s tweak the access control rules. These rules define who can access the data stored in a collection, as well as how they can do so. By default, all CRUD operations on a collection are admin-only.
To set up access control, click on the gear icon next to the collection name and click on the API Rules tab:

These are the access rules for the three collections. First, the posts collection access rules:

Next, the comments collection access rules:

Finally, the users collection access rules:

We’ve defined two kinds of rules here. This rule allows only registered and logged-in users to perform any action:
@request.auth.id != ""
While this rule allows only the user who created that record to perform any action on it:
@request.auth.id = author_id.id // defined for posts collection @request.auth.id = author.id // defined for comments collection id = @request.auth.id // defined users collection
PocketBase allows developers to set complex access control rules, with more than 15 operators available for you to define those access control rules according to your needs.
PocketBase ships with a JavaScript SDK that we can use for seamless communication with the PocketBase server. To install the SDK, run this command:
npm i pocketbase
Once the SDK is installed, let’s create a utility that we can call in our React components to access PocketBase easily:
// ./src/pocketbaseUtils.ts
import PocketBase from "pocketbase";
const pb = new PocketBase(import.meta.env.VITE_POCKETBASE_URL);
export function checkIfLoggedIn(): boolean {
return pb.authStore.isValid;
}
export async function initiateSignUp() {
await pb.collection("users").authWithOAuth2({ provider: "github" });
}
export function logout() {
pb.authStore.clear();
}
export function getPb() {
return pb;
}
This utility is handy for accessing the PocketBase instance from anywhere in our app. It also ensures that we make a single connection to the PocketBase server. We describe four functions in the utility above:
checkIfLoggedIn: Tells the caller if the user is logged in. It looks at the isValid property on the authStore on the PocketBase instanceinitiateSignUp: Initiates sign-in with GitHub. It calls the authWithOAuth2 method, to which we pass the OAuth2 provider. The callbacks, acknowledgments, and tokens are all handled by PocketBaselogout: Clears the auth info from the authStore and logs the user outgetPb: A getter method that returns the PocketBase instance to the callerIn this section, we’ll implement a login screen that will look like the below:

Here’s the code we’ll use to accomplish this feature:
/* ./src/routes/Join.tsx */
import { Button, Flex, Heading } from "@chakra-ui/react";
import { initiateSignUp } from "../pocketbaseUtils";
import { useNavigate } from "react-router-dom";
function Join() {
const navigate = useNavigate();
async function join() {
await initiateSignUp();
navigate("/");
}
return (
<>
<Flex
direction="column"
alignItems="center"
height="100%"
justifyContent="center"
>
<Heading>PocketBase Forum Application</Heading>
<Flex justifyContent="space-evenly" width="20%" marginTop="10">
<Button onClick={join}>Sign In with Github</Button>
</Flex>
</Flex>
</>
);
}
export default Join;
The <Join/> component here allows users to log into our forum application with their GitHub account. The Join component is mounted on the /join path as configured in the react-router config in the previous steps.
One thing to note is the join function that gets called when the Sign In with GitHub button is clicked. This calls the join function which in turn calls the initiateSignUp function from pocketbaseUtils.
Before we start building our UI components, let’s take a look at how the components are structured:

We have defined two routes in the React Router config: the root / route and the /join route.
On the root / route, we will load the Home component. The / route is protected or private — i.e., it should only be accessible to logged-in users. So, we add it as a child of the <PrivateRoute> component in the React Router config.
We will take a quick look at the PrivateRoute component first
// ./src/routes/PrivateRoute.tsx
import { Navigate, Outlet } from "react-router-dom";
import { checkIfLoggedIn } from "../pocketbaseUitl";
const PrivateRoute = () => {
return checkIfLoggedIn() ? <Outlet /> : <Navigate to="/join" />;
};
export default PrivateRoute;
This is a pretty straightforward component. It checks if the user is logged in. If yes, then the child component is rendered; if not, then the user is navigated to the /join route.
Now let’s take a look at the Home component that we render if the user is logged in:
/* ./src/routes/Home.tsx */
import { Flex } from "@chakra-ui/react";
import { useEffect, useState } from "react";
import { getPb } from "../pocketbaseUitl";
import { Post } from "../components/Post";
import { RawPost } from "../types";
import { convertItemsToRawPost } from "../utils";
import { SubmitPost } from "../components/SubmitPost";
import { Navigation } from "../components/navigation";
const Home = () => {
const [posts, setPosts] = useState<RawPost[]>([]);
useEffect(() => {
getPosts();
}, []);
async function getPosts() {
const pb = getPb();
const { items } = await pb
.collection("posts")
.getList(1, 20, { expand: "author_id" });
const posts: RawPost[] = convertItemsToRawPost(items);
setPosts(posts);
}
return (
<Flex direction="column">
<Navigation />
<SubmitPost onSubmit={getPosts} />
{posts?.map((p) => (
<Post post={p} key={p.id} openComments={openCommentsModal} />
))}
</Flex>
);
};
export default Home;
In the code above, we get the PocketBase instance from the utility function. Then, we get a list of posts along with the author details.
The getList function has pagination built in. Here, we’re fetching page 1 with 20 records per page. We use the expand option from PocketBase to get the related tables data — in this case the users table.
We have introduced three new components here:
Post: For displaying posts submitted by members of the forumSubmitPost: For submitting a PostNavigation: For navigating around the app. We will take a look at this component laterHere’s a demo of how the Post and SubmitPost components would work together:

Let’s quickly take a look at the Post component:
/* ./src/components/Post.tsx */
import {
Flex,
IconButton,
Image,
Text,
Textarea,
useToast,
} from "@chakra-ui/react";
import { RawPost } from "../types";
import { GoHeart, GoComment } from "react-icons/go";
import { format } from "date-fns";
import { BiLike } from "react-icons/bi";
import { RiDeleteBin5Line, RiCheckFill } from "react-icons/ri";
import { getPb } from "../pocketbaseUitl";
import { GrEdit } from "react-icons/gr";
import { GiCancel } from "react-icons/gi";
import { useState } from "react";
const pb = getPb();
export const Post = ({
post,
}: {
post: RawPost;
}) => {
const [updateMode, setUpdateMode] = useState<boolean>(false);
const [updatedPostText, setUpdatedPostText] = useState<string>(
post.post_text
);
const toast = useToast();
async function deletePost() {
try {
await pb.collection("posts").delete(post.id);
toast({
title: "Post deleted",
description: "Post deleted successfully.",
status: "success",
});
} catch (e) {
toast({
title: "Post deletion failed",
description: "Couldn't delete the post. Something went wrong.",
status: "error",
});
}
}
async function updatePost() {
try {
await pb
.collection("posts")
.update(post.id, { post_text: updatedPostText });
toast({
title: "Post updated",
description: "Post updated successfully.",
status: "success",
});
setUpdateMode(false);
} catch (e) {
toast({
title: "Post updation failed",
description: "Couldn't update the post. Something went wrong.",
status: "error",
});
}
}
return (
<Flex flexDirection="column" margin="5">
<Flex flexDirection="column">
<Flex alignItems="center">
<Image
src={`https://source.boringavatars.com/beam/120/${post.author.username}`}
height="10"
marginRight="3"
/>
<Flex flexDirection="column">
<Text fontWeight="bold">{post.author.username}</Text>
<Text fontSize="13">{format(post.created, "PPP p")}</Text>
</Flex>
</Flex>
</Flex>
<Flex marginY="4">
{updateMode ? (
<Flex flexDirection="column" flex={1}>
<Textarea
value={updatedPostText}
onChange={(e) => setUpdatedPostText(e.target.value)}
rows={2}
/>
<Flex flexDirection="row" marginTop="2" gap="3">
<IconButton
icon={<RiCheckFill />}
aria-label="submit"
backgroundColor="green.400"
color="white"
size="sm"
onClick={updatePost}
/>
<IconButton
icon={<GiCancel />}
aria-label="cross"
backgroundColor="red.400"
color="white"
size="sm"
onClick={() => {
setUpdateMode(false);
}}
/>
</Flex>
</Flex>
) : (
<Text>{post.post_text}</Text>
)}
</Flex>
<Flex>
<Flex>
<IconButton
icon={<GoHeart />}
aria-label="love"
background="transparent"
/>
<IconButton
icon={<BiLike />}
aria-label="like"
background="transparent"
/>
{post.author_id === pb.authStore.model!.id && (
<>
<IconButton
icon={<RiDeleteBin5Line />}
aria-label="delete"
background="transparent"
onClick={deletePost}
/>
<IconButton
icon={<GrEdit />}
aria-label="edit"
background="transparent"
onClick={() => setUpdateMode(true)}
/>
</>
)}
</Flex>
</Flex>
</Flex>
);
};
Although this code block is quite long, if you take a closer look, you’ll see that it’s actually a fairly simple React component.
This Post component allows us to display post details and lets the user edit and delete their posts, or interact with using "love" and "like" icons. It also uses toast notifications to alert the user upon successful or failed post deletions and updates.
There are three things to note here. First, the code block on line number 32 uses the delete function from the collection to delete the post whose id is passed to the function:
await pb.collection("posts").delete(post.id);
Second, the code block on line numbers 48-50 uses the update function on the collection. The first argument is the id of the post that needs to be updated and the second argument is the update object. Here, we’re updating the post text:
await pb.collection("posts").update(post.id, { post_text: updatedPostText });
Finally, the code block on line 125 allows for conditional rendering of the action buttons:
post.author_id === pb.authStore.model!.id
This condition allows the owner of the post to either delete or update it. Even if a malicious user somehow bypasses this check, they won’t be able to delete or update the post because of the access rules we set earlier.
Now, let’s briefly take a look at the SubmitPost component:
import { Button, Flex, Textarea } from "@chakra-ui/react";
import { useState } from "react";
import { getPb } from "../pocketbaseUitl";
import { useToast } from "@chakra-ui/react";
export const SubmitPost = ({ onSubmit }: { onSubmit: () => void }) => {
const [post, setPost] = useState("");
const toast = useToast();
const submitPost = async () => {
const pb = getPb();
try {
await pb.collection("posts").create({
post_text: post,
author_id: pb.authStore.model!.id,
});
toast({
title: "Post submitted.",
description: "Post succesfully submitted",
status: "success",
duration: 7000,
});
onSubmit();
setPost("");
} catch (e: any) {
toast({
title: "Post submission failed",
description: e["message"],
status: "error",
});
}
};
return (
<Flex flexDirection="column" paddingX="20" paddingY="10">
<Textarea
rows={4}
placeholder="What's on your mind?"
value={post}
onChange={(e) => setPost(e.target.value)}
/>
<Flex flexDirection="row-reverse" marginTop="5">
<Button backgroundColor="teal.400" color="white" onClick={submitPost}>
Submit
</Button>
</Flex>
</Flex>
);
};
As before, this is a simple React component that provides a text area in the UI for users to write and submit posts. Note that the code block on line 11 uses the create function on the collection to create a post with text and the author as the currently logged-in user:
await pb.collection("posts").create({
post_text: post,
author_id: pb.authStore.model!.id,
});
What’s the use of posts if no one can comment on them? As a next step, let’s add a comment feature on posts to our forum application. Here’s how our comment functionality will look:

We’ll create a Comments component to set up the modal, while the individual comments will be Comment components.
To start, let’s modify the Home component:
import { Flex, useDisclosure } from "@chakra-ui/react";
import { useEffect, useState } from "react";
import { getPb } from "../pocketbaseUitl";
import { Post } from "../components/Post";
import { RawPost } from "../types";
import { convertItemsToRawPost } from "../utils";
import { SubmitPost } from "../components/SubmitPost";
import { Navigation } from "../components/navigation";
import Comments from "../components/Comments";
import NewPosts from "../components/NewPosts";
const Home = () => {
const [posts, setPosts] = useState<RawPost[]>([]);
const { isOpen, onOpen, onClose } = useDisclosure();
const [openCommentsFor, setOpenCommentsFor] = useState("");
const openCommentsModal = (postId: string) => {
onOpen();
setOpenCommentsFor(postId);
};
useEffect(() => {
getPosts();
}, []);
async function getPosts() {
const pb = getPb();
const { items } = await pb
.collection("posts")
.getList(1, 20, { expand: "author_id" });
const posts: RawPost[] = convertItemsToRawPost(items);
setPosts(posts);
}
return (
<Flex direction="column">
<Navigation />
<SubmitPost onSubmit={getPosts} />
<NewPosts />
{posts?.map((p) => (
<Post post={p} key={p.id} openComments={openCommentsModal} />
))}
{isOpen && (
<Comments isOpen={isOpen} onClose={onClose} postId={openCommentsFor} />
)}
</Flex>
);
};
export default Home;
Here we introduced a new component Comment that accepts the following props:
isOpen: For toggling the visibility of the comments modalonClose: A callback to execute when the comments modal is closedpostId: To specify the post for which comments need to be shownWe also add a openComments prop to the Post component. We will modify the Post component next:
import {
Flex,
IconButton,
Image,
Text,
Textarea,
useToast,
} from "@chakra-ui/react";
import { RawPost } from "../types";
import { GoHeart, GoComment } from "react-icons/go";
import { format } from "date-fns";
import { BiLike } from "react-icons/bi";
import { RiDeleteBin5Line, RiCheckFill } from "react-icons/ri";
import { getPb } from "../pocketbaseUitl";
import { GrEdit } from "react-icons/gr";
import { GiCancel } from "react-icons/gi";
import { useState } from "react";
const pb = getPb();
export const Post = ({
post,
openComments,
}: {
post: RawPost;
openComments: (postId: string) => void;
}) => {
const [updateMode, setUpdateMode] = useState<boolean>(false);
const [updatedPostText, setUpdatedPostText] = useState<string>(
post.post_text
);
const toast = useToast();
async function deletePost() {
try {
await pb.collection("posts").delete(post.id);
toast({
title: "Post deleted",
description: "Post deleted successfully.",
status: "success",
});
} catch (e) {
toast({
title: "Post deletion failed",
description: "Couldn't delete the post. Something went wrong.",
status: "error",
});
}
}
async function updatePost() {
try {
await pb
.collection("posts")
.update(post.id, { post_text: updatedPostText });
toast({
title: "Post updated",
description: "Post updated successfully.",
status: "success",
});
setUpdateMode(false);
} catch (e) {
toast({
title: "Post updation failed",
description: "Couldn't update the post. Something went wrong.",
status: "error",
});
}
}
return (
<Flex flexDirection="column" margin="5">
<Flex flexDirection="column">
<Flex alignItems="center">
<Image
src={`https://source.boringavatars.com/beam/120/${post.author.username}`}
height="10"
marginRight="3"
/>
<Flex flexDirection="column">
<Text fontWeight="bold">{post.author.username}</Text>
<Text fontSize="13">{format(post.created, "PPP p")}</Text>
</Flex>
</Flex>
</Flex>
<Flex marginY="4">
{updateMode ? (
<Flex flexDirection="column" flex={1}>
<Textarea
value={updatedPostText}
onChange={(e) => setUpdatedPostText(e.target.value)}
rows={2}
/>
<Flex flexDirection="row" marginTop="2" gap="3">
<IconButton
icon={<RiCheckFill />}
aria-label="submit"
backgroundColor="green.400"
color="white"
size="sm"
onClick={updatePost}
/>
<IconButton
icon={<GiCancel />}
aria-label="cross"
backgroundColor="red.400"
color="white"
size="sm"
onClick={() => {
setUpdateMode(false);
}}
/>
</Flex>
</Flex>
) : (
<Text>{post.post_text}</Text>
)}
</Flex>
<Flex>
<Flex>
<IconButton
icon={<GoHeart />}
aria-label="love"
background="transparent"
/>
<IconButton
icon={<BiLike />}
aria-label="like"
background="transparent"
/>
<IconButton
icon={<GoComment />}
aria-label="like"
background="transparent"
onClick={() => {
openComments(post.id);
}}
/>
{post.author_id === pb.authStore.model!.id && (
<>
<IconButton
icon={<RiDeleteBin5Line />}
aria-label="delete"
background="transparent"
onClick={deletePost}
/>
<IconButton
icon={<GrEdit />}
aria-label="edit"
background="transparent"
onClick={() => setUpdateMode(true)}
/>
</>
)}
</Flex>
</Flex>
</Flex>
);
};
Here’s a closer look at the update we made to our Post component on lines 126-133:
<IconButton
icon={<GoComment />}
aria-label="like"
background="transparent"
onClick={() => {
openComments(post.id);
}}
/>
To summarize, we added a comment icon in the Post component. When a user clicks on this icon, the openComments method passed as a prop to the Post component is executed. This then opens the comment modal.
Now that we have set the trigger for opening the comments, let’s take a look at the Comments component:
/* ./src/components/Comments.tsx */
import {
Flex,
Textarea,
Button,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
useToast,
} from "@chakra-ui/react";
import { useEffect, useState } from "react";
const pb = getPb();
import { getPb } from "../pocketbaseUitl";
import { convertItemsToComments } from "../utils";
import { RawComment } from "../types";
import Comment from "./Comment";
export default function Comments({
isOpen,
onClose,
postId,
}: {
isOpen: boolean;
onClose: () => void;
postId: string;
}) {
const [comment, setComment] = useState<string>("");
const [comments, setComments] = useState<RawComment[]>([]);
const toast = useToast();
const submitComment = async () => {
try {
await pb.collection("comments").create({
comment_text: comment,
author: pb.authStore.model!.id,
post: postId,
});
loadComments();
toast({
title: "Comment Submitted",
description: "Comment submitted successfully",
status: "success",
});
setComment("");
} catch (e) {
toast({
title: "Comment Submission",
description: "Comment submission failed",
status: "error",
});
}
};
async function loadComments() {
const result = await pb
.collection("comments")
.getList(1, 10, { filter: `post="${postId}"`, expand: "author" });
const comments = convertItemsToComments(result.items);
setComments(comments);
}
useEffect(() => {
loadComments();
}, []);
return (
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>Comments</ModalHeader>
<ModalCloseButton />
<ModalBody>
<Flex flexDirection="column">
<Flex flexDirection="column">
<Textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="What do you think?"
/>
<Flex flexDirection="row-reverse">
<Button
backgroundColor="teal.400"
color="white"
marginTop="3"
onClick={submitComment}
>
Comment
</Button>
</Flex>
</Flex>
{comments.map((c) => (
<Comment comment={c} key={c.id} loadComments={loadComments} />
))}
</Flex>
</ModalBody>
</ModalContent>
</Modal>
);
}
Four things need to be noted here:
loadComments function. Just like posts, we use the getList function available on the comments collection. Similar to our Posts component, we use the expand option to get information about the author of the comment. Additionally, we pass in a filter that filters comments by postIdloadComments function inside the useEffect HooksubmitComment function that creates a new comment in the comments collection. Upon successful submission of a comment, we call the loadComments function again to fetch all the comments made on the postComment component to display comments in the modal. This Comment component accepts the comment object, a key to identify comments uniquely (required by React), and the loadComments functionNow, let’s quickly take a look at the Comment component:
/* ./src/components/Comment.tsx */
import {
Flex,
IconButton,
Image,
Text,
Textarea,
useToast,
} from "@chakra-ui/react";
import { RawComment } from "../types";
import { format } from "date-fns";
import { useState } from "react";
import { GrEdit } from "react-icons/gr";
import { GiCancel } from "react-icons/gi";
import { RiDeleteBin5Line, RiCheckFill } from "react-icons/ri";
import { getPb } from "../pocketbaseUitl";
const pb = getPb();
export default function Comment({
comment,
loadComments,
}: {
comment: RawComment;
loadComments: () => void;
}) {
const toast = useToast();
const [updateMode, setUpdateMode] = useState<boolean>(false);
const [updatedCommentText, setUpdatedCommentText] = useState<string>(
comment.comment_text
);
async function deleteComment() {
try {
await pb.collection("comments").delete(comment.id);
toast({
title: "Comment deleted",
description: "Comment deleted successfully.",
status: "success",
});
loadComments();
} catch (e) {
toast({
title: "Comment deletion failed",
description: "Couldn't delete the comment. Something went wrong.",
status: "error",
});
}
}
async function updateComment() {
try {
await pb
.collection("comments")
.update(comment.id, { comment_text: updatedCommentText });
toast({
title: "Comment updated",
description: "Comment updated successfully.",
status: "success",
});
loadComments();
setUpdateMode(false);
} catch (e) {
toast({
title: "Comment updation failed",
description: "Couldn't update the comment. Something went wrong.",
status: "error",
});
}
}
return (
<Flex flexDirection="column">
<Flex>
<Image
src={`https://source.boringavatars.com/beam/120/${comment.author.username}`}
height="10"
marginRight="3"
/>
<Flex flexDirection="column">
<Text fontWeight="bold">{comment.author.username}</Text>
<Text fontSize="12">{format(comment.created, "PPP p")}</Text>
</Flex>
</Flex>
<Flex>
{updateMode ? (
<Flex marginY="3" flex="1">
<Textarea
value={updatedCommentText}
onChange={(e) => setUpdatedCommentText(e.target.value)}
rows={1}
/>
<Flex flexDirection="row" marginTop="2" gap="3">
<IconButton
icon={<RiCheckFill />}
aria-label="submit"
backgroundColor="green.400"
color="white"
size="sm"
onClick={updateComment}
/>
<IconButton
icon={<GiCancel />}
aria-label="cross"
backgroundColor="red.400"
color="white"
size="sm"
onClick={() => {
setUpdateMode(false);
}}
/>
</Flex>
</Flex>
) : (
<Flex marginY="3" flex="1">
<Text>{comment.comment_text}</Text>
</Flex>
)}
{comment.author.email === pb.authStore.model!.email && (
<Flex flexDirection="row">
<IconButton
icon={<RiDeleteBin5Line />}
aria-label="delete"
backgroundColor="transparent"
onClick={deleteComment}
/>
<IconButton
icon={<GrEdit />}
aria-label="edit"
backgroundColor="transparent"
onClick={() => setUpdateMode(true)}
/>
</Flex>
)}
</Flex>
</Flex>
);
}
This component is very similar to the Post component in terms of functionality. We use the delete and update functions on the comments collection to perform actions on the record. Also, we allow only the owner of the comment to perform these actions on the comment.
PocketBase offers out-of-the-box support for subscriptions. This allows users to listen to changes made to a collection. Let’s try to build a notification system with this feature, which we’ll add to our Navigation component:
![]()
Let’s try to add subscriptions to the navigation component. Whenever someone comments on a post made by the logged-in user, the notification counter in the nav bar increases by one:

Here’s the code for our navigation component, updated to include the notification feature:
/* ./src/components/navigation */
import { Flex, Text, Button, IconButton, Image } from "@chakra-ui/react";
import { getPb, logout } from "../pocketbaseUitl";
import { useNavigate } from "react-router-dom";
import { BiBell } from "react-icons/bi";
import { useEffect, useState } from "react";
const pb = getPb();
export const Navigation = () => {
const navigate = useNavigate();
const [notificationCount, setNotificationCount] = useState<number>(0);
const logoutUser = () => {
logout();
navigate("/join");
};
useEffect(() => {
pb.collection("comments").subscribe(
"*",
(e) => {
if (e.record.expand?.post.author_id === pb.authStore.model!.id) {
setNotificationCount(notificationCount + 1);
}
},
{ expand: "post" }
);
return () => {
pb.collection("comments").unsubscribe();
};
}, []);
return (
<Flex direction="row" alignItems="center">
<Text fontWeight="bold" flex="3" fontSize="22">
PocketBase Forum Example
</Text>
<Flex>
<Flex alignItems="center" marginX="5">
<Button backgroundColor="transparent">
<BiBell size="20" />
{notificationCount && (
<Flex
borderRadius="20"
background="red.500"
p="2"
marginLeft="2"
height="60%"
alignItems="center"
>
<Text color="white" fontSize="12">
{notificationCount}
</Text>
</Flex>
)}
</Button>
</Flex>
<Button onClick={logoutUser} colorScheme="red" color="white">
Logout
</Button>
<Image
marginLeft="5"
height="10"
src={`https://source.boringavatars.com/beam/120/${
pb.authStore.model!.username
}`}
/>
</Flex>
</Flex>
);
};
The code below, which is on lines 15-28 above, is of particular interest to us:
useEffect(() => {
pb.collection("comments").subscribe(
"*",
(e) => {
if (e.record.expand?.post.author_id === pb.authStore.model!.id) {
setNotificationCount(notificationCount + 1);
}
},
{ expand: "post" }
);
return () => {
pb.collection("comments").unsubscribe();
};
}, []);
We use the subscribe method on the collection to listen to changes made to the comments collection.
We want to show a notification when a new comment is added to a post made by the logged-in user. So, we subscribe to all the changes by passing in * as the first argument to the subscribe function.
When a new record gets added to the comments collection the server sends an event to all the subscribers with the newly created record as a payload. We check if the comment is made on a post authored by the logged-in user. If yes, we increment the notification counter and show it in the navigation bar.
We use the useEffect Hook with no dependencies to ensure that the client is subscribed only once and we dispose of the subscription when the component is unmounted.
If you’re trying to build an MVP or quickly test out a business idea to see if it has any legs, PocketBase can be a huge time and effort saver. Most of the features required to build an MVP — like authentication, file uploads, real-time subscriptions, and access control rules — are baked into the PocketBase framework.
Also, since PocketBase is Go-based, it performs better than Node.js under heavy loads. Overall, if you’re looking to move fast and experiment with some business ideas, PocketBase can help you do just that with minimal effort.
All the code for the tutorial is available here.
That’s it! Thank you for reading!

Vibe coding isn’t just AI-assisted chaos. Here’s how to avoid insecure, unreadable code and turn your “vibes” into real developer productivity.

GitHub SpecKit brings structure to AI-assisted coding with a spec-driven workflow. Learn how to build a consistent, React-based project guided by clear specs and plans.

:has(), with examplesThe CSS :has() pseudo-class is a powerful new feature that lets you style parents, siblings, and more – writing cleaner, more dynamic CSS with less JavaScript.

Kombai AI converts Figma designs into clean, responsive frontend code. It helps developers build production-ready UIs faster while keeping design accuracy and code quality intact.
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up now
One Reply to "Using PocketBase to build a full-stack application"
very nice. we use this alot in ai.supremacy.sg