- NoC: PocketBase, Fresh, Deno Workshop Walkthrough
- 1. Install Deno
- 2. Create a Fresh project
- 3. Start the development server
- 4. Update
importsin thedeno.json - 5. Create a PocketBase instance on PocketHost and setup
.envfiles - 6. Setup collections and API rules via PocketBase's admin dashboard
- 7. Create a sample user in the
userscollection - 8. Load ENV variables
- 9. Create shared types file
- 10. Create
helpers.tsfile - 11. Implement auth logic
- 12. Implement a global middleware
- 13. Add base styles
- 14. Update the
indexroute - 15. Create a
loginroute - 16. Create a
logoutroute - 17. Create a
notesroute - 18. Create a note detail route (
notes/[id]/index.tsx) - 19. Create a
notes/[id]/editroute - 20. Implement
create,deleteandunlinkAPIs - 21. Push to GitHub
- 22. Deploy to Deno Deploy
- Done! 🎉
- To-do
- Resources
1. Install Deno
macOS and Linux:
curl -fsSL https://deno.land/install.sh | shWindows:
irm https://deno.land/install.ps1 | iex- Run the following command to initialize a Fresh project (this also creates a project folder):
deno run -A -r https://fresh.deno.dev noc-workshop- When prompted for a styling library, answer "Yes" (y) and choose Tailwind.
- Navigate to the project folder and start the development server:
cd noc-workshop && deno task startThe development server starts on port
8000by default: http://localhost:8000.
- Add
$/path entry mapped to./: This allows us to use absolute imports throughout the project. - Update the
stdversion to0.218.2: Set the std version to0.218.2in order to get access tohttp/cookie.tsmodule. - Add
npm:pocketbase: This is PocketBase's official JavaScript SDK that is required for the implementation of the application. - Add
https://deno.land/x/zodenv@v2.0.1/mod.ts: This is a helper library that is used to parse env variables.
// deno.json
{
"imports": {
...
"$/": "./",
"$std/": "https://deno.land/std@0.218.2/",
"pocketbase": "npm:pocketbase",
"zodenv": "https://deno.land/x/zodenv@v2.0.1/mod.ts"
}
}Ideally, a fixed version for PocketBase should be used to avoid breaking changes in the future, but for some reason, VSCode's extension (or perhaps Deno itself) bugs out and fails to cache the dependency, this is why the latest version is used instead.
5. Create a PocketBase instance on PocketHost and setup .env files
You can use a local instance of PocketBase for development and testing, but for production, a hosted instance should be used. You can self host but for the purposes of the workshop, PocketHost is used to simplify the setup.
- Navigate to the PocketHost website and create a new PocketBase instance.
- Copy the URL of the instance from the dashboard and save it into an
.envfile at the root of the project.
POCKET_BASE_URL="<pocket-host-instance-url>"
.envfiles are used to store environment variables for the application. They are not committed to the repository and are used to store sensitive data such as API keys, database credentials, etc.
- Create
.env.examplefile with a sample value.
POCKET_BASE_URL="http://localhost:8090"This file is be used to provide a template for other developers who want to contribute to the project. It should be committed to the repository.
- Create
.env.defaultsfile with the same value as the.env.example.
POCKET_BASE_URL="http://localhost:8090"This file is be used to provide default values for the environment variables. It should be committed to the repository. Without this file, the deployment build fails as
zodenvlibrary throws throw an error for undefined env variables (this due to the fact that.envis not present during the build step in GitHub Actions). This can be mitigated by setting default value in the code, but this is a more tool-agnostic solution.
6. Setup collections and API rules via PocketBase's admin dashboard
- Navigate to the admin panel of your PocketBase instance. The link should be available on your PocketHost dashboard.
Credentials are the same as ones you set when creating the instance.
- Create a new collection called
noteswith the following fields:title- plain textbody- rich textuser- relation touserscollection (single)
- Once the
notescollection is created, update it by adding a new field (click on the ⚙️ icon):links- relation tonotes(multiple) (This is used to link notes to each other)
- Setup the API rules for the
notescollection (⚙️ -> API Rules):- List/Search:
@request.auth.id = user.id - View:
@request.auth.id = user.id - Create:
@request.auth.id != '' - Update:
@request.auth.id = user.id - Delete:
@request.auth.id = user.id
- List/Search:
- Navigate to the admin panel of your PocketBase instance.
- Create a new user in the
userscollection.
This is required to test the auth logic and the general implementation of the app.
- Create an
env.tsfile in thesharedfolder to load and parse the environment variables.
Quick tip: If you're using VSCode, you can type the nested path to the file when creating it. E.g.
shared/env.ts. This creates thesharedfolder and theenv.tsfile inside it.
// shared/env.ts
import { load } from "$std/dotenv/mod.ts";
import { parse } from "zodenv";
await load({
export: true,
});
export const [config, env] = parse((e) => ({
POCKET_BASE_URL: e.url(),
}));- Create a
types.tsfile in thesharedfolder to define the shared types and constants used throughout the application.
// shared/types.ts
import Pocketbase from "pocketbase";
export type User = {
id: string;
avatar: string; // File name
email: string;
username: string;
name: string;
avatarUrl: string;
};
export type Note = {
id: string;
title: string;
body: string;
created: string;
updated: string;
expand?: {
links: Note[];
};
};
export type State = {
pb: Pocketbase;
user?: User;
};
export enum AuthCookie {
Name = "auth",
MaxAge = 60 * 60 * 24 * 30,
SameSite = "Strict",
}
- The
Statetype is used to type the app context state in route handlers and page data props.UserandNotetypes are used to type the data received from the PocketBase API.AuthCookieenum is used to set the name and options for the auth cookie.
- Create a
helpers.tsfile in thesharedfolder.
// shared/helpers.ts
export function redirect(path: string, headers = new Headers()) {
headers.set("Location", path);
return new Response(null, {
headers,
status: 303,
});
}This helper function is used throughout the application to redirect the user to a different page.
- Create an
auth.tsfile in thesharedfolder to handle the user's session and authentication.
- Load the user's session from the auth cookie.
- Create a new auth cookie when the user logs in.
- Clear the auth cookie when the user logs out.
- Provide a context state to the rest of the application.
// shared/auth.ts
import Pocketbase from "pocketbase";
import { FreshContext } from "$fresh/server.ts";
import { setCookie } from "$std/http/cookie.ts";
import { env } from "$/shared/env.ts";
import { AuthCookie, State, User } from "$/shared/types.ts";
export async function createState(headers: Headers): Promise<State> {
const pb = new Pocketbase(env("POCKET_BASE_URL"));
let user: User | undefined;
getUser: {
pb.authStore.loadFromCookie(headers.get("cookie") ?? "", AuthCookie.Name);
if (!pb.authStore.isValid) {
break getUser;
}
try {
/**
* The user record needs to be refreshed to get the latest data. This is
* because the user's data might have changed since the last time they
* logged in.
*/
const { record } = await pb.collection("users").authRefresh<User>();
user = record;
user.avatarUrl = new URL(
`/api/files/users/${user.id}/${user.avatar}`,
env("POCKET_BASE_URL")
).toString();
} catch (error) {
console.error(error);
}
}
return { pb, user };
}
export function createAuthCookieHeaders(ctx: FreshContext<State>): Headers {
const headers = new Headers();
const { pb } = ctx.state;
const { hostname } = ctx.url;
const authCookie = pb.authStore.exportToCookie(
{
maxAge: Number(AuthCookie.MaxAge),
sameSite: String(AuthCookie.SameSite),
secure: !hostname.startsWith("localhost"), // Safari...
},
AuthCookie.Name
);
headers.set("set-cookie", authCookie);
return headers;
}
export function createAuthCookieClearHeaders(): Headers {
const headers = new Headers();
setCookie(headers, {
name: String(AuthCookie.Name),
value: "",
maxAge: 0,
});
return headers;
}12. Implement a global middleware
- Create a
_middleware.tsfile to check if the user has a valid session and provide the context state to the rest of the routes.
// routes/_middleware.ts
import { FreshContext } from "$fresh/server.ts";
import { createState } from "$/shared/auth.ts";
import { redirect } from "$/shared/helpers.ts";
import { State } from "$/shared/types.ts";
export async function handler(req: Request, ctx: FreshContext<State>) {
/**
* If anything other than a route is being requested, we don't need to check
* for the user session. E.g. requesting static files.
*/
if (ctx.destination !== "route") {
return ctx.next();
}
ctx.state = await createState(req.headers);
const isLoginRoute = ctx.url.pathname === "/login";
if (!ctx.state.user) {
/**
* If the user is not logged in and the route is not the login route,
* redirect the user to the login page. Otherwise, continue to the next
* handler.
*/
return isLoginRoute ? ctx.next() : redirect("/login");
}
/**
* If the user is logged in and the route is the login route, redirect the
* user to the home page. Otherwise, continue to the next handler.
*/
return isLoginRoute ? redirect("/") : ctx.next();
}- To make the app look a bit more presentable, add some base styles to the
styles.cssfile located in thestaticfolder.
/* static/styles.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
h1,
h2,
h3,
h4,
h5,
h6 {
@apply font-semibold font-mono;
}
h1 {
@apply text-4xl;
}
h2 {
@apply text-3xl;
}
h3 {
@apply text-2xl;
}
h4 {
@apply text-xl;
}
h5 {
@apply text-lg;
}
a {
@apply underline font-mono;
}
small {
@apply font-mono;
}
input,
textarea {
@apply border border-gray-300 p-3 rounded-md;
}
textarea {
@apply resize-y;
}
fieldset {
@apply flex flex-col gap-2 min-w-[15rem] p-3 rounded-md relative isolate;
@apply border border-gray-300;
& > legend {
@apply text-sm px-2 py-0.5 rounded-md w-fit;
}
}
button {
@apply py-2 px-4 font-mono rounded-md border border-gray-300 hover:bg-gray-100;
@apply backdrop-blur-md transition-colors duration-200;
form > &[type="submit"]:only-of-type:not(:only-child) {
@apply mt-4 self-center;
}
}
form {
@apply flex flex-col gap-6;
}
ul {
@apply flex flex-col gap-4;
}
main {
@apply max-w-sm mx-auto text-center flex flex-col gap-8 p-6;
}
}14. Update the index route
- Update the
indexroute to display the user's name and username and provide a link to thenotespage.
// routes/index.tsx
import { Head } from "$fresh/runtime.ts";
import { PageProps } from "$fresh/server.ts";
import { State } from "$/shared/types.ts";
export default function Home({ state }: PageProps<never, State>) {
return (
<>
<Head>
<title>Night of Notes</title>
</Head>
<main>
<span class="text-4xl">📋</span>
<h1>Night of Notes</h1>
<p>
Welcome to Night of Notes! This is a simple note-taking app built with{" "}
<a href="https://fresh.deno.dev">Fresh</a>.
</p>
<small>
You're logged in as {state.user?.name} (@{state.user?.username}).
</small>
<nav>
<ul>
<li>
<a href="/notes">Notes</a>
</li>
</ul>
</nav>
</main>
</>
);
}- Create a
loginroute to handle the user's login.
Fresh uses server-side rendering (SSR) to render the page on the server and send it to the client. This means that the page is rendered on the server and sent to the client as HTML, which is then hydrated by the client-side JavaScript if required.
Since we have the power of the server, we can use handlers to handle data-fetching and form submissions. In this case, we use a
POSThandler to process the user's login.
// routes/login.tsx
import { Handlers, PageProps } from "$fresh/server.ts";
import { createAuthCookieHeaders } from "$/shared/auth.ts";
import { redirect } from "$/shared/helpers.ts";
import { State } from "$/shared/types.ts";
type LoginResult = {
errorMessage?: string;
};
export default function Login({ data }: PageProps<LoginResult>) {
return (
<main class="mt-36">
<h1>Login</h1>
<form method="POST">
<input
name="identity"
type="text"
aria-label="Email or username"
placeholder="Enter your email or username"
required
/>
<input
name="password"
type="password"
aria-label="Password"
placeholder="Enter your password"
required
/>
<button type="submit">Login</button>
{data?.errorMessage && <p>{data.errorMessage}</p>}
</form>
</main>
);
}
export const handler: Handlers<LoginResult, State> = {
POST: async (req, ctx) => {
const formData = await req.formData();
const identity = formData.get("identity")?.toString();
const password = formData.get("password")?.toString();
if (!identity || !password) {
return ctx.render({
errorMessage: "Missing identity or password",
});
}
try {
await ctx.state.pb
.collection("users")
.authWithPassword(identity, password);
const headers = createAuthCookieHeaders(ctx);
return redirect("/", headers);
} catch (error) {
console.error(error);
return ctx.render({
errorMessage: error.message,
});
}
},
};If you don't get redirected to the login page or if accessing the page leads back to the home page, you might have a session cookie stored in your browser either from previous testing or from some other app that ran on the same port. You can clear the cookies for the localhost domain in the dev tools to fix this.
- Create a
logoutroute to handle the user's logout.
// routes/logout.ts
import { Handlers } from "$fresh/server.ts";
import { createAuthCookieClearHeaders } from "$/shared/auth.ts";
import { redirect } from "$/shared/helpers.ts";
import { State } from "$/shared/types.ts";
export const handler: Handlers<never, State> = {
GET: () => redirect("/"),
POST: () => {
/**
* Clear the auth cookie and redirect the user to the login page.
*/
return redirect("/login", createAuthCookieClearHeaders());
},
};- Create a
notesroute to display the user's notes and provide a form to create a new note.
// routes/notes/index.tsx
import { Head } from "$fresh/runtime.ts";
import { Handlers, PageProps } from "$fresh/server.ts";
import { Note, State } from "$/shared/types.ts";
type NotesData = {
notes: Note[];
};
export default function Notes({ data }: PageProps<NotesData, State>) {
const { notes } = data;
return (
<>
<Head>
<title>Notes</title>
<meta
name="description"
content="Here is the collection of your notes."
/>
</Head>
<main>
<a href="/">← Back to home</a>
<h1>Notes</h1>
<ul class="flex flex-col gap-6">
{notes.map((note) => (
<li key={note.id}>
<a class="no-underline font-sans" href={`/notes/${note.id}`}>
<article class="flex flex-col gap-4 text-left bg-gray-100/20 rounded-md p-4">
<h2>{note.title}</h2>
<p>
{note.body.length > 100
? note.body.slice(0, 100) + "..."
: note.body}
</p>
<address class="flex flex-wrap gap-2 text-xs bg-red-100/20 rounded-md p-2">
<time dateTime={note.created} title="Created at">
{note.created}
</time>
<time dateTime={note.updated}>
Last modified: {note.updated}
</time>
</address>
</article>
</a>
</li>
))}
</ul>
<form
class="fixed bottom-6 right-6"
action="/api/notes/create"
method="POST"
>
<button type="submit" title="Create new note">
+
</button>
</form>
</main>
</>
);
}
export const handler: Handlers<NotesData, State> = {
GET: async (_req, ctx) => {
const notes = await ctx.state.pb.collection("notes").getFullList<Note>({
expand: "links",
});
return ctx.render({
notes,
});
},
};The
createAPI is implemented in later steps...
- Create a
notes/[id]/index.tsxroute to display the details of a note and provide a form to edit the note.
// routes/notes/[id]/index.tsx
import { Head } from "$fresh/runtime.ts";
import { Handlers, PageProps } from "$fresh/server.ts";
import { Note, State } from "$/shared/types.ts";
type NoteDetailData = {
note: Note;
};
export default function NoteDetail({ data }: PageProps<NoteDetailData, State>) {
const { note } = data;
return (
<>
<Head>
<title>{note.title}</title>
</Head>
<main class="max-w-[60ch]">
<a href="/notes">← Back to notes</a>
<article class="flex flex-col gap-6 text-left">
<h1 class="flex flex-wrap gap-4 items-center justify-between">
{note.title}{" "}
<form action={`/notes/${note.id}/edit`}>
<button class="text-xs p-2" type="submit">
✏️ Edit
</button>
</form>
</h1>
<div dangerouslySetInnerHTML={{ __html: note.body }}></div>
</article>
<h2>🔗 Linked notes</h2>
{(note.expand?.links.length ?? 0) === 0 ? (
<p class="note">You don't have any links for this note.</p>
) : (
<ul>
{note.expand?.links.map((link) => (
<li key={link.id}>
<a href={`/notes/${link.id}`}>{link.title}</a>
</li>
))}
</ul>
)}
<footer></footer>
</main>
</>
);
}
export const handler: Handlers<NoteDetailData, State> = {
GET: async (_req, ctx) => {
const note = await ctx.state.pb
.collection("notes")
.getOne<Note>(ctx.params.id, {
expand: "links",
});
if (!note) {
return ctx.renderNotFound();
}
return ctx.render({
note,
});
},
};- Create a
notes/[id]/editroute to handle the editing of a note:- Display the form to edit the note.
- Provide a list of notes to link to the current note.
- Provide buttons to cancel, delete, and save the note.
// routes/notes/[id]/edit.tsx
import { Head } from "$fresh/runtime.ts";
import { FreshContext, Handlers, PageProps } from "$fresh/server.ts";
import { redirect } from "$/shared/helpers.ts";
import { Note, State } from "$/shared/types.ts";
type NoteEditData = {
note: Note;
notes: Note[];
errorMessage?: string;
};
export default function NoteEdit({ data }: PageProps<NoteEditData>) {
const { note, notes } = data;
return (
<>
<Head>
<title>Edit Note</title>
</Head>
<main class="max-w-[60ch]">
<h1>Edit Note</h1>
<form id="edit" method="POST">
<input
name="title"
type="text"
aria-label="Title"
value={note.title}
placeholder="Enter the title of the note"
required
/>
<textarea
name="body"
aria-label="Body"
value={note.body}
placeholder="Enter the body of the note"
rows={10}
></textarea>
{notes.length > 0 && (
<fieldset>
<legend>Select notes that you want to link to this note</legend>
<select name="links" multiple>
{notes.map((option) => {
const selected = note.expand?.links.some(
(linked) => linked.id === option.id
);
const prefix = selected ? "🔗 " : "";
const suffix = selected ? " (linked)" : "";
const truncatedTitle = option.title.slice(
0,
selected ? 12 : 20
);
return (
<option
key={option.id}
value={option.id}
selected={selected}
>
{`${prefix} ${truncatedTitle} ${suffix}...`}
</option>
);
})}
</select>
</fieldset>
)}
</form>
<footer class="flex flex-wrap gap-4 justify-center">
<form class="contents" method="GET" action={`/notes/${note.id}`}>
<button type="submit">Cancel</button>
</form>
{(note.expand?.links.length ?? 0) > 0 && (
<form
class="contents"
method="POST"
action={`/api/notes/${note.id}/unlink`}
>
<button class="bg-indigo-100" type="submit">
Unlink all notes
</button>
</form>
)}
<form
class="contents"
method="POST"
action={`/api/notes/${note.id}/delete`}
>
<button class="bg-red-100" type="submit">
Delete
</button>
</form>
<button class="bg-green-100" form="edit" type="submit">
Save
</button>
</footer>
{data?.errorMessage && <p>{data.errorMessage}</p>}
</main>
</>
);
}
async function renderNotes(
ctx: FreshContext<State>,
props?: Partial<NoteEditData>
) {
const note = await ctx.state.pb
.collection("notes")
.getOne<Note>(ctx.params.id, {
expand: "links",
});
const notes = await ctx.state.pb.collection("notes").getFullList<Note>({
expand: "links",
});
if (!note) {
return ctx.renderNotFound();
}
return ctx.render({
note,
notes: notes.filter((n) => n.id !== note.id),
...props,
});
}
export const handler: Handlers<NoteEditData, State> = {
GET: async (_req, ctx) => {
return await renderNotes(ctx);
},
POST: async (req, ctx) => {
const formData = await req.formData();
const title = formData.get("title")?.toString();
const body = formData.get("body")?.toString();
const links = formData.getAll("links");
if (!title) {
return await renderNotes(ctx, {
errorMessage: "Title is required",
});
}
const { id } = ctx.params;
await ctx.state.pb.collection("notes").update(id, {
title,
body,
links,
});
return redirect(`/notes/${id}`);
},
};API routes are used to handle data-fetching and form submissions. They are similar to the regular routes but they don't render pages.
- Implement the
createAPI inroutes/api/notes/create.ts:
// routes/api/notes/create.ts
import { Handlers } from "$fresh/server.ts";
import { redirect } from "$/shared/helpers.ts";
import { State } from "$/shared/types.ts";
export const handler: Handlers<never, State> = {
POST: async (_req, ctx) => {
if (!ctx.state.user) {
return new Response(null, {
status: 401,
statusText: "Unauthorized",
});
}
const data = new FormData();
data.append("title", "New Note");
data.append("user", ctx.state.user.id);
const note = await ctx.state.pb.collection("notes").create(data);
return redirect(`/notes/${note.id}/edit`);
},
};- Implement the
deleteAPI inroutes/api/notes/[id]/delete.ts:
// routes/api/notes/[id]/delete.ts
import { Handlers } from "$fresh/server.ts";
import { redirect } from "$/shared/helpers.ts";
import { State } from "$/shared/types.ts";
export const handler: Handlers<never, State> = {
POST: async (_req, ctx) => {
const { id } = ctx.params;
await ctx.state.pb.collection("notes").delete(id);
return redirect("/notes");
},
};- Implement the
unlinkAPI inroutes/api/notes/[id]/unlink.ts:
// routes/api/notes/[id]/unlink.ts
import { Handlers } from "$fresh/server.ts";
import { redirect } from "$/shared/helpers.ts";
import { State } from "$/shared/types.ts";
export const handler: Handlers<never, State> = {
POST: async (_req, ctx) => {
const { id } = ctx.params;
await ctx.state.pb.collection("notes").update(id, {
links: [],
});
return redirect(`/notes/${id}/edit`);
},
};21. Push to GitHub
- Create a repository on GitHub (make sure not to initialize it).
- If you haven't already, initialize a local git repository in the root of the project and connect it to the GitHub remote.
git init
git add .
git commit -m "feat: implement simple note-taking app"
git branch -M main
git remote add origin <your-github-repo-url>
git push -u origin main22. Deploy to Deno Deploy
- Go to dash.deno.com and create a new project.
- Connect your GitHub repository to the project, choose the GitHub Actions workflow as the deployment method.
- In the settings of the Deno Deploy project, add the following environment variables:
POCKET_BASE_URL- The URL of your PocketBase instance on PocketHost.
- In the repository, update the project name in the
deploy.ymlfile to match the name of your project on Deno Deploy.
jobs:
deploy:
# ...
steps:
# ...
- name: Upload to Deno Deploy
uses: denoland/deployctl@v1
with:
project: "noc-workshop" # use the name of your project here
entrypoint: "./main.ts"- Stage and commit the changes to the
deploy.ymlfile and push them to the remote.
git add deploy.yaml
git commit -m "ci: update project name"
git push -u origin mainThis triggers the GitHub Actions workflow and deploys the application to Deno Deploy.
- Once the deployment is complete, you can visit the live application by clicking on the deployment link in the Deno Deploy dashboard. The link format is
https://<your-project-name>.deno.dev.
You have successfully created a full-stack application using Fresh, Deno, and PocketBase! I hope you enjoyed this workshop and learned something new. I sure did!
If you have any questions or feedback, you can contact UNIIT or reach out to me directly via LinkedIn. You can also check out my personal website and my blog.
Cheers,
Niza ✌️
P.S. Thanks to Štefan Mašán for the help with the workshop and the review of the content.
If you want to continue working on the application, here are some ideas for improvements:
- Use
pocketbase-typegento generate types for the collections instead of manually creating them. - Implement pagination for the
notes/index.tsxroute. - Use enums for route paths. E.g.
Routes.Notesinstead of/notes. - Extract repeating logic into reusable components/islands. E.g.
NoteCard,NoteForm,NoteList. - Use two step confirmation for logout, note deletion, unlinking notes.
- Use a release branch or a release tag for the deployment workflow.