add blog post, update syntax highlighting cc7ca697
Steve · 2024-09-24 12:04 3 file(s) · +719 −3
astro.config.mjs +2 −2
16 16
	},
17 17
	markdown: {
18 18
		shikiConfig: {
19 -
			theme: "css-variables",
20 -
			wrap: true,
19 +
			theme: "vesper",
20 +
			wrap: false,
21 21
		},
22 22
	},
23 23
	prefetch: true,
src/content/post/building-a-guestbook-with-pglite-clerk-and-pinata.mdx (added) +716 −0
1 +
---
2 +
title: "Building a Guestbook with PGlite, Clerk, and Pinata"
3 +
publishDate: "24 Sep 2024"
4 +
description: "A quick walkthough of how I built a guestbook for my website"
5 +
tags: ["programming", "developer tools", "pinata"]
6 +
ogImage: "https://dweb.mypinata.cloud/ipfs/QmU4XNzvRej9soBFdShhSb3KiTpN45hziDCbzdc5hBW1Nk?img-format=webp"
7 +
---
8 +
9 +
When I was first getting started in web development I remember seeing someone's website and was immediately impressed by one thing: a guestbook. You could sing in with Github and leave a message, similar to someone's Facebook wall back in the day. I thought that was the coolest thing but had no idea how to build it. Fast forward to this weekend, I was reminded how cool that was and I decided to build it for my own website.
10 +
11 +
Normally I would use Supabase DB + Auth for something like this for the ease of use, but I wanted to take a slightly different route. I've been playing with PGlite quite a bit in the last few weeks and decided it would be fun to see if I can host it as a server. My coworker Justin recently had a [post about building a CRUD app with Deno, SQLite and Pinata](https://pinata.cloud/blog/how-to-build-a-persistent-crud-app-using-sqlite-and-deno-js/) to handle backups, and it seemed like the perfect setup to pair with PGlite.
12 +
13 +
With a weekend to spare I built this out and you can check it out now with [this link](/guestbook)! In this post I'll show you how I built the server, integrated Clerk auth into both the server and the backend, and finally rendering it out into a UI for people to use.
14 +
15 +
## Setup
16 +
17 +
When it came to setting up this project there were several moving pieces that had to work together.
18 +
19 +
### Pinata
20 +
21 +
Naturally since I work for Pinata I already have an account ready to go for this project, but if you haven't tried it yet then you really should! Creating your account, getting your API key, and firing up the SDK takes just minutes to add file uploads to your account. It was actually the easiest piece of this project, so don't be shy and try it out [here](https://app.pinata.cloud/register).
22 +
23 +
### Clerk
24 +
25 +
Since I wasn't going to use Supabase for this project I decided to try out [Clerk](https://clerk.com). I've heard nothing but good things, and there's a reason for that. This platform truly understands how to make auth easy while also giving you loads of control if you want it. Setting it up for my website and the server was super simple, just followed the quick start guide when I onboarded and it was done. Since I'm using Github as my login method I also followed [this guide](https://clerk.com/docs/authentication/social-connections/github) to set that piece up.
26 +
27 +
### Server
28 +
29 +
There are so many options out there for building a server/API, so by all means use what feels best for your needs, but I personally love using Hono via Bun. Starting up the project was simple as the command below:
30 +
31 +
```
32 +
bun create hono guestbook-db
33 +
```
34 +
35 +
Once the repo was created and initialized I installed a few other packages I would need.
36 +
37 +
```
38 +
bun add pinata @clerk/backend @hono/clerk-auth @electric-sql/pglite croner
39 +
```
40 +
41 +
With everything installed I did some initial setup for the project. In the `index.ts` file I imported my dependencies, setup some of the middleware like Clerk and CORS, and just have an entry point setup.
42 +
43 +
```typescript
44 +
import { Hono } from "hono";
45 +
import { cors } from "hono/cors";
46 +
import { PGlite } from "@electric-sql/pglite";
47 +
import { pinata } from "./pinata";
48 +
import { Cron } from "croner";
49 +
import { clerkMiddleware, getAuth } from "@hono/clerk-auth";
50 +
51 +
const app = new Hono();
52 +
53 +
app.use("/*", cors());
54 +
app.use("*", clerkMiddleware());
55 +
56 +
app.get("/", (c) => {
57 +
	return c.text("Welcome!");
58 +
});
59 +
60 +
export default app;
61 +
```
62 +
63 +
The only other file I needed to add was in the same `src` folder called `pinata.ts` with a quick export of the Pinata SDK instance.
64 +
65 +
```typescript pinata.ts
66 +
import { PinataSDK } from "pinata";
67 +
68 +
export const pinata = new PinataSDK({
69 +
	pinataJwt: process.env.PINATA_JWT,
70 +
	pinataGateway: process.env.GATEWAY_URL,
71 +
});
72 +
```
73 +
74 +
Finally we have a simple `.env` to handle our secrets and vars.
75 +
76 +
```
77 +
PINATA_JWT= # The primary Pinata API key
78 +
GATEWAY_URL= # Your Pinata gateway domain e.g. example.mypinata.cloud
79 +
GROUP_ID= # Group ID where we will store backups
80 +
CLERK_PUBLISHABLE_KEY= # Clerk public key
81 +
CLERK_SECRET_KEY= # Clerk private key
82 +
ADMIN_KEY= # optional key for your own overrides
83 +
```
84 +
85 +
## Backend
86 +
87 +
With the database server setup it was time to work on adding in the database itself. PGlite is unique in that it runs on WASM, making it lightweight and operable in a browser. For this project I took advantage of the size by creating a backup and restore feature into the server. Instead of relying on a local disk setup, the server will routinely backup copies of the database to Pinata, and if at any point it needs to restart it will reboot from the last backup. Depending on how your database library works you could also add it writing to disk as well for extra security.
88 +
89 +
To setup this workflow I created the following `initDb` function and call it using an immediately invoked function expression.
90 +
91 +
```typescript
92 +
async function initDb(): Promise<string> {
93 +
	try {
94 +
		const files = await pinata.files
95 +
			.list()
96 +
			.group(process.env.GROUP_ID!)
97 +
			.order("DESC");
98 +
		if (files.files) {
99 +
			const dbFile = await pinata.gateways.get(files.files[0].cid);
100 +
			const file = dbFile.data as Blob;
101 +
			db = new PGlite({ loadDataDir: file });
102 +
			return files.files[0].created_at;
103 +
		}
104 +
		db = new PGlite("./guestbook");
105 +
		await db.exec(`
106 +
	       CREATE TABLE IF NOT EXISTS messages (
107 +
	         id SERIAL PRIMARY KEY,
108 +
	         note TEXT,
109 +
	         author TEXT,
110 +
	         user_id TEXT,
111 +
	         pfp_url TEXT,
112 +
	         username TEXT
113 +
	       );
114 +
     `);
115 +
		return "New DB Created";
116 +
	} catch (error) {
117 +
		console.log(error);
118 +
		throw error;
119 +
	}
120 +
}
121 +
122 +
(async () => {
123 +
	try {
124 +
		const status = await initDb();
125 +
		console.log("Database initialized. Snapshot:", status);
126 +
	} catch (error) {
127 +
		console.log("Failed to initialize database:", error);
128 +
	}
129 +
})();
130 +
```
131 +
132 +
This initialization function will first check if there is a backup file using Pinata. We keep the files organized by creating a `group`, and this allows us to filter our files by said group and order them by date. This group was created beforehand, and you could use a script like the following to do so.
133 +
134 +
```typescript
135 +
import { PinataSDK } from "pinata";
136 +
137 +
const pinata = new PinataSDK({
138 +
  pinataJwt: process.env.PINATA_JWT!,
139 +
  pinataGateway: "example-gateway.mypinata.cloud",
140 +
});
141 +
142 +
const group = await pinata.groups.create({
143 +
	name: "My New Group"
144 +
});
145 +
```
146 +
147 +
If there is a database backup in the group, then we download it as a file using the SDK once more with `pinata.gateways.get`. Then we simply create and load up a new instance of the database using `new PGPlite({ loadDataDir: file })`. However if there isn't a database backup on Pinata, the function will create a new instance of DB locally and create the default table. Then it's just a matter of calling the function as soon as the server starts!
148 +
149 +
With the database setup and ready to go it was time to start building some endpoints. The first one would be a simple `GET /messages` to fetch all current rows in the database and return them as JSON. Additionally we structured the query to reverse the feed results so the most recent would be at the top.
150 +
151 +
```typescript
152 +
app.get("/messages", async (c) => {
153 +
	if (db) {
154 +
		const ret = await db.query(`
155 +
		SELECT * FROM messages ORDER BY id DESC LIMIT 50;
156 +
  `);
157 +
		return c.json(ret.rows);
158 +
	}
159 +
	return c.text("Restore database first");
160 +
});
161 +
```
162 +
163 +
Of course we'll need an endpoint to actually add messages to the database and we'll use that with the same route `/messages` but as a `POST` method.
164 +
165 +
```typescript
166 +
interface Message {
167 +
	note: string;
168 +
	author: string;
169 +
	user_id: string;
170 +
	username: string;
171 +
}
172 +
173 +
app.post("/messages", async (c) => {
174 +
	const body = (await c.req.json()) as Message;
175 +
176 +
	const auth = getAuth(c);
177 +
	const clerkClient = c.get("clerk");
178 +
179 +
	if (!auth?.userId) {
180 +
		return c.json(
181 +
			{
182 +
				message: "You are not logged in.",
183 +
			},
184 +
			401,
185 +
		);
186 +
	}
187 +
188 +
	if (!body.note || typeof body.note !== "string") {
189 +
		return c.json({ error: "Invalid note" }, 400);
190 +
	}
191 +
192 +
	const user = await clerkClient.users.getUser(auth?.userId);
193 +
194 +
	try {
195 +
		if (db && auth) {
196 +
			const res = await db.query(
197 +
				"INSERT INTO messages (note, author, user_id, pfp_url, username) VALUES ($1, $2, $3, $4, $5)",
198 +
				[body.note, user.firstName, auth?.userId, user.imageUrl, user.username],
199 +
			);
200 +
201 +
			return c.json(res.rows);
202 +
		}
203 +
	} catch (error) {
204 +
		console.error("Error creating message:", error);
205 +
		return c.json({ error: "Failed to create message" }, 500);
206 +
	}
207 +
});
208 +
```
209 +
210 +
Here is where things start to get good. Instead of having the client send a full JSON payload, we can use Clerk to securely fetch some of that information for us as we use Github OAuth as the only authentication method. In the database we have the `note`, `author` or name of the writer, `user_id` from Clerk which we'll get into later, and the `username` for link to the writer's Github profile. All we have to do is use the Clerk Hono middleware to get the `auth` object and verify the user is logged in, otherwise we send a 401. We also make sure that the `note` attached from the client is a string. Finally we can get a `user` object from Clerk using the `userId` and getting all the information we need! Then we just insert a row into the table and return it back to the client.
211 +
212 +
In the event that someone mistyped something and wanted to delete their message, or if someone left something unkind, we will want a method to delete it. This time we'll use `DELETE /messages/:id` and get the `id` of a message using the path param.
213 +
214 +
```typescript
215 +
app.delete("/messages/:id", async (c) => {
216 +
	const id = c.req.param("id");
217 +
	const auth = getAuth(c);
218 +
	const admin = c.req.header("Authorization");
219 +
220 +
	if (!auth?.userId && admin !== process.env.ADMIN_KEY) {
221 +
		return c.json(
222 +
			{
223 +
				message: "You are not logged in.",
224 +
			},
225 +
			401,
226 +
		);
227 +
	}
228 +
229 +
	try {
230 +
		if (db) {
231 +
			const checkQuery = await db.query<MessageRow>(
232 +
				"SELECT user_id FROM messages WHERE id = $1",
233 +
				[id],
234 +
			);
235 +
236 +
			if (checkQuery.rows.length === 0) {
237 +
				return c.json({ error: "Message not found" }, 404);
238 +
			}
239 +
240 +
			const messageUserId = checkQuery.rows[0].user_id;
241 +
242 +
			if (admin !== process.env.ADMIN_KEY && auth?.userId !== messageUserId) {
243 +
				return c.json(
244 +
					{ error: "You are not authorized to delete this message" },
245 +
					403,
246 +
				);
247 +
			}
248 +
249 +
			const res = await db.query("DELETE FROM messages WHERE id = $1", [id]);
250 +
251 +
			if (res.affectedRows === 0) {
252 +
				return c.json({ error: "Message not found" }, 404);
253 +
			}
254 +
			return c.text("Ok");
255 +
		}
256 +
	} catch (error) {
257 +
		console.error("Error deleting message:", error);
258 +
		return c.json({ error: "Failed to delete message" }, 500);
259 +
	}
260 +
});
261 +
```
262 +
263 +
With this endpoint we have to check a few things. First we need to grab the `id` of the target note. Then we need to do a general auth check to see if the requester is a user or the admin (me). If they pass that then we do a query of the messages for that note `id`, and then we do a check if the requester is either the admin or the author of the note. If they pass then we delete the row from the table and send back an `Ok` message.
264 +
265 +
That really covers the majority of what I wanted in the guestbook, but if we wanted to we could easily add a `PUT` message as well to enable editing old messages. There are a few things left to do though, including our `/restore` and `/backup` routes.
266 +
267 +
```typescript
268 +
app.post("/restore", async (c) => {
269 +
	const admin = c.req.header("Authorization");
270 +
271 +
	if (admin !== process.env.ADMIN_KEY) {
272 +
		return c.json(
273 +
			{
274 +
				message: "You are not logged in.",
275 +
			},
276 +
			401,
277 +
		);
278 +
	}
279 +
280 +
	try {
281 +
		await initDb();
282 +
		return c.text("Ok");
283 +
	} catch (error) {
284 +
		console.error("Error restoring database:", error);
285 +
		return c.json({ error: "Failed to restore database." }, 500);
286 +
	}
287 +
});
288 +
289 +
app.post("/backup", async (c) => {
290 +
	const admin = c.req.header("Authorization");
291 +
292 +
	if (admin !== process.env.ADMIN_KEY) {
293 +
		return c.json(
294 +
			{
295 +
				message: "You are not logged in.",
296 +
			},
297 +
			401,
298 +
		);
299 +
	}
300 +
301 +
	try {
302 +
		if (db) {
303 +
			const dbFile = (await db.dumpDataDir("auto")) as File;
304 +
			const upload = await pinata.upload
305 +
				.file(dbFile)
306 +
				.group(process.env.GROUP_ID ?? "");
307 +
			return c.json(upload);
308 +
		}
309 +
	} catch (error) {
310 +
		console.error("Error backing up database:", error);
311 +
		return c.json({ error: "Failed to backup database" }, 500);
312 +
	}
313 +
});
314 +
```
315 +
316 +
These are really simple routes but play an important role in our database. The `/restore` route is a manual reset without restarting the server, where it runs our `initDb()` function from the beginning. `/backup` will dump our current database state as a compact zip file into our Pinata group using the best upload experience: `pinata.upload.file` *chef's kiss*. We'll use the same logic in our cron job to regularly backup the database.
317 +
318 +
```typescript
319 +
const job = Cron("0 0 * * *", async () => {
320 +
	if (db) {
321 +
		const dbFile = (await db.dumpDataDir("auto")) as File;
322 +
		const upload = await pinata.upload
323 +
			.file(dbFile)
324 +
			.group(process.env.GROUP_ID ?? "");
325 +
		console.log(upload);
326 +
	}
327 +
});
328 +
```
329 +
330 +
Just like that our server is ready!
331 +
332 +
## Front End
333 +
334 +
Now the fun part, pulling everything together in the app 😎 To start I needed to install and setup Clerk for Astro using [this guide](https://clerk.com/docs/quickstarts/astro). To stay somewhat in my comfort zone I used the React plugin for Astro so I could make a component that handles everything, and once again Clerk [made that easy too](https://clerk.com/docs/references/astro/react).
335 +
336 +
I made a new component called `GuestbookFeed.tsx` and slowly built out the following:
337 +
338 +
```typescript
339 +
import { useStore } from "@nanostores/react";
340 +
import { $sessionStore, $userStore } from "@clerk/astro/client";
341 +
import { useState, useEffect } from "react";
342 +
import {
343 +
	SignedIn,
344 +
	SignedOut,
345 +
	UserButton,
346 +
	SignUpButton,
347 +
} from "@clerk/astro/react";
348 +
349 +
type Message = {
350 +
	id: number;
351 +
	note: string;
352 +
	author: string;
353 +
	user_id: string;
354 +
	pfp_url: string;
355 +
	username: string;
356 +
};
357 +
358 +
const API_URL = import.meta.PUBLIC_API_ENDPOINT
359 +
360 +
export default function GuestbookFeed() {
361 +
	const [messages, setMessages] = useState<Message[]>([]);
362 +
	const [isLoading, setIsLoading] = useState(true);
363 +
	const [isSending, setIsSending] = useState(false);
364 +
	const [inputText, setInputText] = useState("");
365 +
	const session = useStore($sessionStore);
366 +
	const user = useStore($userStore);
367 +
368 +
	async function fetchMessages() {
369 +
		setIsLoading(true);
370 +
		try {
371 +
			const req = await fetch(`${API_URL}/messages`);
372 +
			const res = await req.json();
373 +
			console.log(res);
374 +
			setMessages(res);
375 +
		} catch (error) {
376 +
			console.log(error);
377 +
		} finally {
378 +
			setIsLoading(false);
379 +
		}
380 +
	}
381 +
382 +
	function inputHandeler(e) {
383 +
		setInputText(e.target.value);
384 +
	}
385 +
386 +
	async function sendMessage() {
387 +
		setIsSending(true);
388 +
		try {
389 +
			const req = await fetch(`${API_URL}/messages`, {
390 +
				method: "POST",
391 +
				headers: {
392 +
					Authorization: `Bearer ${await session.getToken()}`,
393 +
				},
394 +
				body: JSON.stringify({ note: inputText }),
395 +
			});
396 +
			const res = await req.json();
397 +
			console.log(res);
398 +
			setInputText("");
399 +
			setIsSending(false);
400 +
			await fetchMessages();
401 +
		} catch (error) {
402 +
			console.log(error);
403 +
			setIsSending(false);
404 +
		}
405 +
	}
406 +
407 +
	async function deleteMessage(id: number) {
408 +
		try {
409 +
			const req = await fetch(`${API_URL}/messages/${id}`, {
410 +
				method: "DELETE",
411 +
				headers: {
412 +
					Authorization: `Bearer ${await session.getToken()}`,
413 +
				},
414 +
			});
415 +
			const res = await req.json();
416 +
			console.log(res);
417 +
			await fetchMessages();
418 +
		} catch (error) {
419 +
			console.log(error);
420 +
		}
421 +
	}
422 +
423 +
	useEffect(() => {
424 +
		fetchMessages();
425 +
	}, []);
426 +
427 +
	return (
428 +
		<div className="flex flex-col gap-6">
429 +
			<div className="">
430 +
				<SignedOut>
431 +
					<SignUpButton
432 +
						signInForceRedirectUrl="/guestbook"
433 +
						signInFallbackRedirectUrl="/guestbook"
434 +
						forceRedirectUrl="/guestbook"
435 +
						mode="modal"
436 +
						className="border-2 border-current rounded-md py-1 px-2 cursor-pointer"
437 +
					>
438 +
						Sign in with Github
439 +
					</SignUpButton>
440 +
				</SignedOut>
441 +
				<SignedIn>
442 +
					<div className="flex items-start gap-4 w-full">
443 +
						<UserButton
444 +
							appearance={{
445 +
								layout: {
446 +
									animations: false,
447 +
								},
448 +
							}}
449 +
							afterSignOutUrl="/guestbook"
450 +
						/>
451 +
						<input
452 +
							className="p-1 bg-bgColor border-current border-2 rounded-md w-96"
453 +
							type="text"
454 +
							onChange={inputHandeler}
455 +
							value={inputText}
456 +
						/>
457 +
						<button
458 +
							className="border-2 border-current rounded-md py-1 px-2 cursor-pointer"
459 +
							onClick={sendMessage}
460 +
							type="button"
461 +
						>
462 +
							{isSending ? "Posting..." : "Post"}
463 +
						</button>
464 +
					</div>
465 +
				</SignedIn>
466 +
			</div>
467 +
			{isLoading ? (
468 +
				<p>Loading...</p>
469 +
			) : (
470 +
				<div className="flex flex-col gap-6">
471 +
					{messages.map((note: Message) => (
472 +
						<div
473 +
							className="flex flex-row justify-between items-start"
474 +
							key={note.id}
475 +
						>
476 +
							<div className="flex flex-row gap-2 items-start">
477 +
								<a
478 +
									className="flex-shrink-0 h-7 w-7"
479 +
									href={`https://github.com/${note.username}`}
480 +
									target="_blank"
481 +
									rel="noreferrer"
482 +
								>
483 +
									<img
484 +
										className="h-full w-full rounded-full object-cover"
485 +
										src={note.pfp_url}
486 +
										alt={note.author}
487 +
									/>
488 +
								</a>
489 +
								<div className="flex flex-col justify-between">
490 +
									<a
491 +
										href={`https://github.com/${note.username}`}
492 +
										className="font-bold text-gray-400"
493 +
										target="_blank"
494 +
										rel="noreferrer"
495 +
									>
496 +
										{note.author}
497 +
									</a>
498 +
									<p className="break-words">{note.note}</p>
499 +
								</div>
500 +
							</div>
501 +
							{user && user.id === note.user_id && (
502 +
								<button
503 +
									onClick={async () => deleteMessage(note.id)}
504 +
									type="button"
505 +
								>
506 +
									x
507 +
								</button>
508 +
							)}
509 +
						</div>
510 +
					))}
511 +
				</div>
512 +
			)}
513 +
		</div>
514 +
	);
515 +
}
516 +
```
517 +
518 +
It's a lot of code to look at, but when you break it down it's pretty easy to understand, so let's do that now. To start we have our main imports at the top.
519 +
520 +
```typescript
521 +
import { $sessionStore, $userStore } from "@clerk/astro/client";
522 +
import { useState, useEffect } from "react";
523 +
import {
524 +
	SignedIn,
525 +
	SignedOut,
526 +
	UserButton,
527 +
	SignUpButton,
528 +
} from "@clerk/astro/react";
529 +
530 +
type Message = {
531 +
	id: number;
532 +
	note: string;
533 +
	author: string;
534 +
	user_id: string;
535 +
	pfp_url: string;
536 +
	username: string;
537 +
};
538 +
539 +
const API_URL = import.meta.env.PUBLIC_API_URL
540 +
541 +
export default function GuestbookFeed() {
542 +
	const [messages, setMessages] = useState<Message[]>([]);
543 +
	const [isLoading, setIsLoading] = useState(true);
544 +
	const [isSending, setIsSending] = useState(false);
545 +
	const [inputText, setInputText] = useState("");
546 +
	const session = useStore($sessionStore);
547 +
	const user = useStore($userStore);
548 +
//...
549 +
```
550 +
551 +
Here we have some simple yet important pieces to our component. The first is our stores like `$sessionStore` and `$userStore`. These are provided by the Clerk middleware and will give us access to the logged in user's session tokens to authorize requests. We also have some neat components from Clerk which we'll get into shortly. Finally we have a slew of state from React to handle inputs and loading states, as well as the `useStore` for our auth stores.
552 +
553 +
Below that we have just a few primary functions.
554 +
555 +
```typescript
556 +
	async function fetchMessages() {
557 +
		setIsLoading(true);
558 +
		try {
559 +
			const req = await fetch(`${API_URL}/messages`);
560 +
			const res = await req.json();
561 +
			console.log(res);
562 +
			setMessages(res);
563 +
		} catch (error) {
564 +
			console.log(error);
565 +
		} finally {
566 +
			setIsLoading(false);
567 +
		}
568 +
	}
569 +
570 +
	function inputHandeler(e) {
571 +
		setInputText(e.target.value);
572 +
	}
573 +
574 +
	async function sendMessage() {
575 +
		setIsSending(true);
576 +
		try {
577 +
			const req = await fetch(`${API_URL}/messages`, {
578 +
				method: "POST",
579 +
				headers: {
580 +
					Authorization: `Bearer ${await session.getToken()}`,
581 +
				},
582 +
				body: JSON.stringify({ note: inputText }),
583 +
			});
584 +
			const res = await req.json();
585 +
			console.log(res);
586 +
			setInputText("");
587 +
			setIsSending(false);
588 +
			await fetchMessages();
589 +
		} catch (error) {
590 +
			console.log(error);
591 +
			setIsSending(false);
592 +
		}
593 +
	}
594 +
595 +
	async function deleteMessage(id: number) {
596 +
		try {
597 +
			const req = await fetch(`${API_URL}/messages/${id}`, {
598 +
				method: "DELETE",
599 +
				headers: {
600 +
					Authorization: `Bearer ${await session.getToken()}`,
601 +
				},
602 +
			});
603 +
			const res = await req.json();
604 +
			console.log(res);
605 +
			await fetchMessages();
606 +
		} catch (error) {
607 +
			console.log(error);
608 +
		}
609 +
	}
610 +
611 +
	useEffect(() => {
612 +
		fetchMessages();
613 +
	}, []);
614 +
```
615 +
616 +
At the top we have our function to fetch messages from our API. We made this a public endpoint so we don't have to authorize it at all, and we just store the array of messages from the database into our `Message` array. Next we have our `inputHandler` as well as our `sendMessage` function, which again just takes the input state and sends it as an API request. What's special here is the `Authorization` header where we use the `await session.getToken()` so that our API can authenticate the request. Simple, clean, and effective. If successful we'll clear the input and refetch the messages. We also have a `deleteMessage` function so the author can delete a note from the site if they want to, and finally we have a simple `useEffect` to load our messages when the page loads.
617 +
618 +
Now all that's left is rendering our UI:
619 +
620 +
```typescript
621 +
	return (
622 +
		<div className="flex flex-col gap-6">
623 +
			<div className="">
624 +
				<SignedOut>
625 +
					<SignUpButton
626 +
						signInForceRedirectUrl="/guestbook"
627 +
						signInFallbackRedirectUrl="/guestbook"
628 +
						forceRedirectUrl="/guestbook"
629 +
						mode="modal"
630 +
						className="border-2 border-current rounded-md py-1 px-2 cursor-pointer"
631 +
					>
632 +
						Sign in with Github
633 +
					</SignUpButton>
634 +
				</SignedOut>
635 +
				<SignedIn>
636 +
					<div className="flex items-start gap-4 w-full">
637 +
						<UserButton
638 +
							appearance={{
639 +
								layout: {
640 +
									animations: false,
641 +
								},
642 +
							}}
643 +
							afterSignOutUrl="/guestbook"
644 +
						/>
645 +
						<input
646 +
							className="p-1 bg-bgColor border-current border-2 rounded-md w-96"
647 +
							type="text"
648 +
							onChange={inputHandeler}
649 +
							value={inputText}
650 +
						/>
651 +
						<button
652 +
							className="border-2 border-current rounded-md py-1 px-2 cursor-pointer"
653 +
							onClick={sendMessage}
654 +
							type="button"
655 +
						>
656 +
							{isSending ? "Posting..." : "Post"}
657 +
						</button>
658 +
					</div>
659 +
				</SignedIn>
660 +
			</div>
661 +
			{isLoading ? (
662 +
				<p>Loading...</p>
663 +
			) : (
664 +
				<div className="flex flex-col gap-6">
665 +
					{messages.map((note: Message) => (
666 +
						<div
667 +
							className="flex flex-row justify-between items-start"
668 +
							key={note.id}
669 +
						>
670 +
							<div className="flex flex-row gap-2 items-start">
671 +
								<a
672 +
									className="flex-shrink-0 h-7 w-7"
673 +
									href={`https://github.com/${note.username}`}
674 +
									target="_blank"
675 +
									rel="noreferrer"
676 +
								>
677 +
									<img
678 +
										className="h-full w-full rounded-full object-cover"
679 +
										src={note.pfp_url}
680 +
										alt={note.author}
681 +
									/>
682 +
								</a>
683 +
								<div className="flex flex-col justify-between">
684 +
									<a
685 +
										href={`https://github.com/${note.username}`}
686 +
										className="font-bold text-gray-400"
687 +
										target="_blank"
688 +
										rel="noreferrer"
689 +
									>
690 +
										{note.author}
691 +
									</a>
692 +
									<p className="break-words">{note.note}</p>
693 +
								</div>
694 +
							</div>
695 +
							{user && user.id === note.user_id && (
696 +
								<button
697 +
									onClick={async () => deleteMessage(note.id)}
698 +
									type="button"
699 +
								>
700 +
									x
701 +
								</button>
702 +
							)}
703 +
						</div>
704 +
					))}
705 +
				</div>
706 +
			)}
707 +
		</div>
708 +
	);
709 +
710 +
```
711 +
712 +
Again, a lot of code, but overall not too complicated. At the top we have our Clerk components that determine what a user sees based on their login state. If they're not logged in then we have a `<SignUpButton />`, and once they do sign in we show them a profile button with `<UserButton />`, and input to put a message in, and a button to [send it](https://youtu.be/RSuLFvalhnQ). Below our Clerk components we have the actual message feed which renders our `messages` array, and includes things like their pfp, name, message, and even an `<a />` tag to link to their Github profile. Finally we also have a little button that will appear for that specific user if they want to delete one of their messages, but not anyone else's.
713 +
714 +
## Wrapping Up
715 +
716 +
Overall this was a really great little project to build and it touches some of the key pieces of the web: uploads/storage, databases, backend APIs, and front end UIs. It gives you a great feel and stretches your understanding of how all of these pieces work together and how the space is moving. Using tools like Pinata or Clerk really give you the best of both worlds when building tools, which is a great developer experience and a solid finished product. Be sure to [drop a message](https://stevedylan.dev/guestbook) there now if you haven't already, and thanks for reading! :)
src/pages/guestbook.astro +1 −1
11 11
  <div class="space-y-6">
12 12
  <div class="flex flex-col gap-2 mb-6">
13 13
    <h1 class="font-bold text-2xl">Guestbook</h1>
14 -
    <p>Welcome to my little digital guestbook! I built this using PGlite, Railway, Clerk, and <a href="https://pinata.cloud" class="style-link" target="_blank" rel="noreferrer">Pinata</a>. Login with your Github account to leave a message!</p>
14 +
    <p>Welcome to my little digital guestbook! I built this using PGlite, Railway, Clerk, and <a href="https://pinata.cloud" class="style-link" target="_blank" rel="noreferrer">Pinata</a>, and you can <a href="/posts/building-a-guestbook-with-pglite-clerk-and-pinata" class="style-link">read about it here</a>. Login with your Github account to leave a message!</p>
15 15
    </div>
16 16
    <GuestbookFeed API_URL={import.meta.env.PUBLIC_API_URL} client:load />
17 17
  </div>