src/content/post/building-a-guestbook-with-pglite-clerk-and-pinata.mdx 24.6 K raw
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", "web development"]
6
ogImage: "../../assets/blog-images/guestbook-cover.png"
7
atUri: "at://did:plc:ia2zdnhjaokf5lazhxrmj6eu/site.standard.document/3mdzvurhwnn2v"
8
---
9
10
![image](../../assets/blog-images/guestbook-cover.png)
11
12
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 sign 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.
13
14
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.
15
16
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.
17
18
## Setup
19
20
When it came to setting up this project there were several moving pieces that had to work together.
21
22
### Pinata
23
24
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).
25
26
### Clerk
27
28
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.
29
30
### Server
31
32
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:
33
34
```
35
bun create hono guestbook-db
36
```
37
38
Once the repo was created and initialized I installed a few other packages I would need.
39
40
```
41
bun add pinata @clerk/backend @hono/clerk-auth @electric-sql/pglite croner
42
```
43
44
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.
45
46
```typescript
47
import { Hono } from "hono";
48
import { cors } from "hono/cors";
49
import { PGlite } from "@electric-sql/pglite";
50
import { pinata } from "./pinata";
51
import { Cron } from "croner";
52
import { clerkMiddleware, getAuth } from "@hono/clerk-auth";
53
54
const app = new Hono();
55
56
app.use("/*", cors());
57
app.use("*", clerkMiddleware());
58
59
app.get("/", (c) => {
60
	return c.text("Welcome!");
61
});
62
63
export default app;
64
```
65
66
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.
67
68
```typescript pinata.ts
69
import { PinataSDK } from "pinata";
70
71
export const pinata = new PinataSDK({
72
	pinataJwt: process.env.PINATA_JWT,
73
	pinataGateway: process.env.GATEWAY_URL,
74
});
75
```
76
77
Finally we have a simple `.env` to handle our secrets and vars.
78
79
```
80
PINATA_JWT= # The primary Pinata API key
81
GATEWAY_URL= # Your Pinata gateway domain e.g. example.mypinata.cloud
82
GROUP_ID= # Group ID where we will store backups
83
CLERK_PUBLISHABLE_KEY= # Clerk public key
84
CLERK_SECRET_KEY= # Clerk private key
85
ADMIN_KEY= # optional key for your own overrides
86
```
87
88
## Backend
89
90
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.
91
92
To setup this workflow I created the following `initDb` function and call it using an immediately invoked function expression.
93
94
```typescript
95
async function initDb(): Promise<string> {
96
	try {
97
		const files = await pinata.files
98
			.list()
99
			.group(process.env.GROUP_ID!)
100
			.order("DESC");
101
		if (files.files) {
102
			const dbFile = await pinata.gateways.get(files.files[0].cid);
103
			const file = dbFile.data as Blob;
104
			db = new PGlite({ loadDataDir: file });
105
			return files.files[0].created_at;
106
		}
107
		db = new PGlite("./guestbook");
108
		await db.exec(`
109
	       CREATE TABLE IF NOT EXISTS messages (
110
	         id SERIAL PRIMARY KEY,
111
	         note TEXT,
112
	         author TEXT,
113
	         user_id TEXT,
114
	         pfp_url TEXT,
115
	         username TEXT
116
	       );
117
     `);
118
		return "New DB Created";
119
	} catch (error) {
120
		console.log(error);
121
		throw error;
122
	}
123
}
124
125
(async () => {
126
	try {
127
		const status = await initDb();
128
		console.log("Database initialized. Snapshot:", status);
129
	} catch (error) {
130
		console.log("Failed to initialize database:", error);
131
	}
132
})();
133
```
134
135
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.
136
137
```typescript
138
import { PinataSDK } from "pinata";
139
140
const pinata = new PinataSDK({
141
  pinataJwt: process.env.PINATA_JWT!,
142
  pinataGateway: "example-gateway.mypinata.cloud",
143
});
144
145
const group = await pinata.groups.create({
146
	name: "My New Group"
147
});
148
```
149
150
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!
151
152
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.
153
154
```typescript
155
app.get("/messages", async (c) => {
156
	if (db) {
157
		const ret = await db.query(`
158
		SELECT * FROM messages ORDER BY id DESC LIMIT 50;
159
  `);
160
		return c.json(ret.rows);
161
	}
162
	return c.text("Restore database first");
163
});
164
```
165
166
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.
167
168
```typescript
169
interface Message {
170
	note: string;
171
	author: string;
172
	user_id: string;
173
	username: string;
174
}
175
176
app.post("/messages", async (c) => {
177
	const body = (await c.req.json()) as Message;
178
179
	const auth = getAuth(c);
180
	const clerkClient = c.get("clerk");
181
182
	if (!auth?.userId) {
183
		return c.json(
184
			{
185
				message: "You are not logged in.",
186
			},
187
			401,
188
		);
189
	}
190
191
	if (!body.note || typeof body.note !== "string") {
192
		return c.json({ error: "Invalid note" }, 400);
193
	}
194
195
	const user = await clerkClient.users.getUser(auth?.userId);
196
197
	try {
198
		if (db && auth) {
199
			const res = await db.query(
200
				"INSERT INTO messages (note, author, user_id, pfp_url, username) VALUES ($1, $2, $3, $4, $5)",
201
				[body.note, user.firstName, auth?.userId, user.imageUrl, user.username],
202
			);
203
204
			return c.json(res.rows);
205
		}
206
	} catch (error) {
207
		console.error("Error creating message:", error);
208
		return c.json({ error: "Failed to create message" }, 500);
209
	}
210
});
211
```
212
213
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.
214
215
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.
216
217
```typescript
218
app.delete("/messages/:id", async (c) => {
219
	const id = c.req.param("id");
220
	const auth = getAuth(c);
221
	const admin = c.req.header("Authorization");
222
223
	if (!auth?.userId && admin !== process.env.ADMIN_KEY) {
224
		return c.json(
225
			{
226
				message: "You are not logged in.",
227
			},
228
			401,
229
		);
230
	}
231
232
	try {
233
		if (db) {
234
			const checkQuery = await db.query<MessageRow>(
235
				"SELECT user_id FROM messages WHERE id = $1",
236
				[id],
237
			);
238
239
			if (checkQuery.rows.length === 0) {
240
				return c.json({ error: "Message not found" }, 404);
241
			}
242
243
			const messageUserId = checkQuery.rows[0].user_id;
244
245
			if (admin !== process.env.ADMIN_KEY && auth?.userId !== messageUserId) {
246
				return c.json(
247
					{ error: "You are not authorized to delete this message" },
248
					403,
249
				);
250
			}
251
252
			const res = await db.query("DELETE FROM messages WHERE id = $1", [id]);
253
254
			if (res.affectedRows === 0) {
255
				return c.json({ error: "Message not found" }, 404);
256
			}
257
			return c.text("Ok");
258
		}
259
	} catch (error) {
260
		console.error("Error deleting message:", error);
261
		return c.json({ error: "Failed to delete message" }, 500);
262
	}
263
});
264
```
265
266
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.
267
268
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.
269
270
```typescript
271
app.post("/restore", async (c) => {
272
	const admin = c.req.header("Authorization");
273
274
	if (admin !== process.env.ADMIN_KEY) {
275
		return c.json(
276
			{
277
				message: "You are not logged in.",
278
			},
279
			401,
280
		);
281
	}
282
283
	try {
284
		await initDb();
285
		return c.text("Ok");
286
	} catch (error) {
287
		console.error("Error restoring database:", error);
288
		return c.json({ error: "Failed to restore database." }, 500);
289
	}
290
});
291
292
app.post("/backup", async (c) => {
293
	const admin = c.req.header("Authorization");
294
295
	if (admin !== process.env.ADMIN_KEY) {
296
		return c.json(
297
			{
298
				message: "You are not logged in.",
299
			},
300
			401,
301
		);
302
	}
303
304
	try {
305
		if (db) {
306
			const dbFile = (await db.dumpDataDir("auto")) as File;
307
			const upload = await pinata.upload
308
				.file(dbFile)
309
				.group(process.env.GROUP_ID ?? "");
310
			return c.json(upload);
311
		}
312
	} catch (error) {
313
		console.error("Error backing up database:", error);
314
		return c.json({ error: "Failed to backup database" }, 500);
315
	}
316
});
317
```
318
319
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.
320
321
```typescript
322
const job = Cron("0 0 * * *", async () => {
323
	if (db) {
324
		const dbFile = (await db.dumpDataDir("auto")) as File;
325
		const upload = await pinata.upload
326
			.file(dbFile)
327
			.group(process.env.GROUP_ID ?? "");
328
		console.log(upload);
329
	}
330
});
331
```
332
333
Just like that our server is ready!
334
335
## Front End
336
337
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).
338
339
I made a new component called `GuestbookFeed.tsx` and slowly built out the following:
340
341
```typescript
342
import { useStore } from "@nanostores/react";
343
import { $sessionStore, $userStore } from "@clerk/astro/client";
344
import { useState, useEffect } from "react";
345
import {
346
	SignedIn,
347
	SignedOut,
348
	UserButton,
349
	SignUpButton,
350
} from "@clerk/astro/react";
351
352
type Message = {
353
	id: number;
354
	note: string;
355
	author: string;
356
	user_id: string;
357
	pfp_url: string;
358
	username: string;
359
};
360
361
const API_URL = import.meta.PUBLIC_API_ENDPOINT
362
363
export default function GuestbookFeed() {
364
	const [messages, setMessages] = useState<Message[]>([]);
365
	const [isLoading, setIsLoading] = useState(true);
366
	const [isSending, setIsSending] = useState(false);
367
	const [inputText, setInputText] = useState("");
368
	const session = useStore($sessionStore);
369
	const user = useStore($userStore);
370
371
	async function fetchMessages() {
372
		setIsLoading(true);
373
		try {
374
			const req = await fetch(`${API_URL}/messages`);
375
			const res = await req.json();
376
			console.log(res);
377
			setMessages(res);
378
		} catch (error) {
379
			console.log(error);
380
		} finally {
381
			setIsLoading(false);
382
		}
383
	}
384
385
	function inputHandeler(e) {
386
		setInputText(e.target.value);
387
	}
388
389
	async function sendMessage() {
390
		setIsSending(true);
391
		try {
392
			const req = await fetch(`${API_URL}/messages`, {
393
				method: "POST",
394
				headers: {
395
					Authorization: `Bearer ${await session.getToken()}`,
396
				},
397
				body: JSON.stringify({ note: inputText }),
398
			});
399
			const res = await req.json();
400
			console.log(res);
401
			setInputText("");
402
			setIsSending(false);
403
			await fetchMessages();
404
		} catch (error) {
405
			console.log(error);
406
			setIsSending(false);
407
		}
408
	}
409
410
	async function deleteMessage(id: number) {
411
		try {
412
			const req = await fetch(`${API_URL}/messages/${id}`, {
413
				method: "DELETE",
414
				headers: {
415
					Authorization: `Bearer ${await session.getToken()}`,
416
				},
417
			});
418
			const res = await req.json();
419
			console.log(res);
420
			await fetchMessages();
421
		} catch (error) {
422
			console.log(error);
423
		}
424
	}
425
426
	useEffect(() => {
427
		fetchMessages();
428
	}, []);
429
430
	return (
431
		<div className="flex flex-col gap-6">
432
			<div className="">
433
				<SignedOut>
434
					<SignUpButton
435
						signInForceRedirectUrl="/guestbook"
436
						signInFallbackRedirectUrl="/guestbook"
437
						forceRedirectUrl="/guestbook"
438
						mode="modal"
439
						className="border-2 border-current rounded-md py-1 px-2 cursor-pointer"
440
					>
441
						Sign in with Github
442
					</SignUpButton>
443
				</SignedOut>
444
				<SignedIn>
445
					<div className="flex items-start gap-4 w-full">
446
						<UserButton
447
							appearance={{
448
								layout: {
449
									animations: false,
450
								},
451
							}}
452
							afterSignOutUrl="/guestbook"
453
						/>
454
						<input
455
							className="p-1 bg-bgColor border-current border-2 rounded-md w-96"
456
							type="text"
457
							onChange={inputHandeler}
458
							value={inputText}
459
						/>
460
						<button
461
							className="border-2 border-current rounded-md py-1 px-2 cursor-pointer"
462
							onClick={sendMessage}
463
							type="button"
464
						>
465
							{isSending ? "Posting..." : "Post"}
466
						</button>
467
					</div>
468
				</SignedIn>
469
			</div>
470
			{isLoading ? (
471
				<p>Loading...</p>
472
			) : (
473
				<div className="flex flex-col gap-6">
474
					{messages.map((note: Message) => (
475
						<div
476
							className="flex flex-row justify-between items-start"
477
							key={note.id}
478
						>
479
							<div className="flex flex-row gap-2 items-start">
480
								<a
481
									className="flex-shrink-0 h-7 w-7"
482
									href={`https://github.com/${note.username}`}
483
									target="_blank"
484
									rel="noreferrer"
485
								>
486
									<img
487
										className="h-full w-full rounded-full object-cover"
488
										src={note.pfp_url}
489
										alt={note.author}
490
									/>
491
								</a>
492
								<div className="flex flex-col justify-between">
493
									<a
494
										href={`https://github.com/${note.username}`}
495
										className="font-bold text-gray-400"
496
										target="_blank"
497
										rel="noreferrer"
498
									>
499
										{note.author}
500
									</a>
501
									<p className="break-words">{note.note}</p>
502
								</div>
503
							</div>
504
							{user && user.id === note.user_id && (
505
								<button
506
									onClick={async () => deleteMessage(note.id)}
507
									type="button"
508
								>
509
									x
510
								</button>
511
							)}
512
						</div>
513
					))}
514
				</div>
515
			)}
516
		</div>
517
	);
518
}
519
```
520
521
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.
522
523
```typescript
524
import { $sessionStore, $userStore } from "@clerk/astro/client";
525
import { useState, useEffect } from "react";
526
import {
527
	SignedIn,
528
	SignedOut,
529
	UserButton,
530
	SignUpButton,
531
} from "@clerk/astro/react";
532
533
type Message = {
534
	id: number;
535
	note: string;
536
	author: string;
537
	user_id: string;
538
	pfp_url: string;
539
	username: string;
540
};
541
542
const API_URL = import.meta.env.PUBLIC_API_URL
543
544
export default function GuestbookFeed() {
545
	const [messages, setMessages] = useState<Message[]>([]);
546
	const [isLoading, setIsLoading] = useState(true);
547
	const [isSending, setIsSending] = useState(false);
548
	const [inputText, setInputText] = useState("");
549
	const session = useStore($sessionStore);
550
	const user = useStore($userStore);
551
//...
552
```
553
554
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.
555
556
Below that we have just a few primary functions.
557
558
```typescript
559
	async function fetchMessages() {
560
		setIsLoading(true);
561
		try {
562
			const req = await fetch(`${API_URL}/messages`);
563
			const res = await req.json();
564
			console.log(res);
565
			setMessages(res);
566
		} catch (error) {
567
			console.log(error);
568
		} finally {
569
			setIsLoading(false);
570
		}
571
	}
572
573
	function inputHandeler(e) {
574
		setInputText(e.target.value);
575
	}
576
577
	async function sendMessage() {
578
		setIsSending(true);
579
		try {
580
			const req = await fetch(`${API_URL}/messages`, {
581
				method: "POST",
582
				headers: {
583
					Authorization: `Bearer ${await session.getToken()}`,
584
				},
585
				body: JSON.stringify({ note: inputText }),
586
			});
587
			const res = await req.json();
588
			console.log(res);
589
			setInputText("");
590
			setIsSending(false);
591
			await fetchMessages();
592
		} catch (error) {
593
			console.log(error);
594
			setIsSending(false);
595
		}
596
	}
597
598
	async function deleteMessage(id: number) {
599
		try {
600
			const req = await fetch(`${API_URL}/messages/${id}`, {
601
				method: "DELETE",
602
				headers: {
603
					Authorization: `Bearer ${await session.getToken()}`,
604
				},
605
			});
606
			const res = await req.json();
607
			console.log(res);
608
			await fetchMessages();
609
		} catch (error) {
610
			console.log(error);
611
		}
612
	}
613
614
	useEffect(() => {
615
		fetchMessages();
616
	}, []);
617
```
618
619
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.
620
621
Now all that's left is rendering our UI:
622
623
```typescript
624
	return (
625
		<div className="flex flex-col gap-6">
626
			<div className="">
627
				<SignedOut>
628
					<SignUpButton
629
						signInForceRedirectUrl="/guestbook"
630
						signInFallbackRedirectUrl="/guestbook"
631
						forceRedirectUrl="/guestbook"
632
						mode="modal"
633
						className="border-2 border-current rounded-md py-1 px-2 cursor-pointer"
634
					>
635
						Sign in with Github
636
					</SignUpButton>
637
				</SignedOut>
638
				<SignedIn>
639
					<div className="flex items-start gap-4 w-full">
640
						<UserButton
641
							appearance={{
642
								layout: {
643
									animations: false,
644
								},
645
							}}
646
							afterSignOutUrl="/guestbook"
647
						/>
648
						<input
649
							className="p-1 bg-bgColor border-current border-2 rounded-md w-96"
650
							type="text"
651
							onChange={inputHandeler}
652
							value={inputText}
653
						/>
654
						<button
655
							className="border-2 border-current rounded-md py-1 px-2 cursor-pointer"
656
							onClick={sendMessage}
657
							type="button"
658
						>
659
							{isSending ? "Posting..." : "Post"}
660
						</button>
661
					</div>
662
				</SignedIn>
663
			</div>
664
			{isLoading ? (
665
				<p>Loading...</p>
666
			) : (
667
				<div className="flex flex-col gap-6">
668
					{messages.map((note: Message) => (
669
						<div
670
							className="flex flex-row justify-between items-start"
671
							key={note.id}
672
						>
673
							<div className="flex flex-row gap-2 items-start">
674
								<a
675
									className="flex-shrink-0 h-7 w-7"
676
									href={`https://github.com/${note.username}`}
677
									target="_blank"
678
									rel="noreferrer"
679
								>
680
									<img
681
										className="h-full w-full rounded-full object-cover"
682
										src={note.pfp_url}
683
										alt={note.author}
684
									/>
685
								</a>
686
								<div className="flex flex-col justify-between">
687
									<a
688
										href={`https://github.com/${note.username}`}
689
										className="font-bold text-gray-400"
690
										target="_blank"
691
										rel="noreferrer"
692
									>
693
										{note.author}
694
									</a>
695
									<p className="break-words">{note.note}</p>
696
								</div>
697
							</div>
698
							{user && user.id === note.user_id && (
699
								<button
700
									onClick={async () => deleteMessage(note.id)}
701
									type="button"
702
								>
703
									x
704
								</button>
705
							)}
706
						</div>
707
					))}
708
				</div>
709
			)}
710
		</div>
711
	);
712
713
```
714
715
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.
716
717
## Wrapping Up
718
719
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! :)
720
721
### Repos
722
723
[Database & API](https://github.com/stevedylandev/guestbook-db)
724
[Astro Site](https://github.com/stevedylandev/stevedsimkins-dev-astro)