Project: Fresh 2 + PostgreSQL + Drizzle blog
Milestone-based build of a post list, detail pages, and a protected create flow, then Markdown, drafts, images, and search
This tutorial expands blueprint 1 into concrete steps. For routing, islands, and middleware syntax see Fresh 2 full-stack development; this page covers only what the blog adds.
Goal and final shape
fresh-blog/
├── routes/posts/[slug].tsx # post detail (server-rendered)
├── routes/index.tsx # post list
├── routes/admin/posts.tsx # protected create page + POST
├── routes/admin/_middleware.ts # access control for the admin area
├── routes/admin/login.tsx # login form (sets a cookie)
├── islands/PostEditor.tsx # the only component that hydrates
├── components/
├── src/db/{client,schema}.ts # Drizzle connection and tables
├── drizzle.config.ts
└── deno.json
Phase one ships the list, detail pages, and a protected create flow. Phase two adds Markdown rendering, drafts, images, and search. The create form uses a native <form method="post">, so the whole site stays readable and writable without JavaScript.
Setup
deno run -Ar jsr:@fresh/init
cd fresh-blog
deno install npm:drizzle-orm npm:drizzle-kit npm:pg npm:@types/pg
Run PostgreSQL locally with Docker and put the connection string in .env:
docker run --name blog-pg -e POSTGRES_PASSWORD=dev-only -p 5432:5432 -d postgres
DATABASE_URL=postgresql://postgres:dev-only@localhost:5432/postgres
Milestone 1: list, detail, protected create
Schema and migrations
import { boolean, pgTable, serial, text, timestamp } from "drizzle-orm/pg-core";
export const posts = pgTable("posts", {
id: serial().primaryKey(),
slug: text().notNull().unique(),
title: text().notNull(),
body: text().notNull(),
published: boolean().notNull().default(false),
createdAt: timestamp().notNull().defaultNow(),
});
The unique() on slug is an acceptance point: duplicate slugs must be rejected by the database, not by an application-level check.
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
const pool = new Pool({ connectionString: Deno.env.get("DATABASE_URL") });
export const db = drizzle(pool);
import { defineConfig } from "drizzle-kit";
export default defineConfig({
out: "./drizzle",
schema: "./src/db/schema.ts",
dialect: "postgresql",
dbCredentials: { url: Deno.env.get("DATABASE_URL")! },
});
Add these to tasks in deno.json (leave the template's dev and check untouched):
{
"tasks": {
"db:generate": "deno run -A --node-modules-dir npm:drizzle-kit generate",
"db:migrate": "deno run -A --env --node-modules-dir npm:drizzle-kit migrate"
}
}
-A is granted only to the locally trusted migration tool (drizzle-kit needs to read config, connect to the database, and write SQL files); it is not the application's runtime permission set. Configure the production process with its own minimal permissions.
--env makes Deno load .env. Run deno task db:generate first, review the SQL under drizzle/, then apply it with deno task db:migrate.
Data-access functions
Keep queries as plain functions shared by routes and tests:
import { desc, eq } from "drizzle-orm";
import { db } from "./client.ts";
import { posts } from "./schema.ts";
export function listPublishedPosts() {
return db.select().from(posts)
.where(eq(posts.published, true))
.orderBy(desc(posts.createdAt));
}
export function getPostBySlug(slug: string) {
return db.select().from(posts).where(eq(posts.slug, slug)).limit(1);
}
export async function createPost(input: { slug: string; title: string; body: string }) {
// Phase one publishes on submit; the draft flow arrives in milestone 2
await db.insert(posts).values({ ...input, published: true });
}
Routes
import { define } from "@/utils.ts";
import { HttpError } from "fresh";
import { getPostBySlug } from "@/src/db/posts.ts";
export default define.page(async (ctx) => {
const [post] = await getPostBySlug(ctx.params.slug);
if (!post || !post.published) {
// Hand it to Fresh's error handling: the response status is a real 404, not a 200 "not found" page
throw new HttpError(404);
}
return (
<main>
<h1>{post.title}</h1>
<article>{post.body}</article>
</main>
);
});
The list page routes/index.tsx likewise calls listPublishedPosts() inside define.page and renders a <ul>. Both are server components; the browser receives no JavaScript for them.
The create page uses a native form plus a POST handler, redirecting with 303 on success:
import { define } from "@/utils.ts";
import { createPost } from "@/src/db/posts.ts";
export const handlers = define.handlers({
async POST(ctx) {
const form = await ctx.req.formData();
const slug = form.get("slug")?.toString() ?? "";
const title = form.get("title")?.toString() ?? "";
const body = form.get("body")?.toString() ?? "";
if (!/^[a-z0-9-]+$/.test(slug) || !title || !body) {
return new Response("invalid input", { status: 400 });
}
try {
await createPost({ slug, title, body });
} catch (err) {
// Map only unique-constraint violations (SQLSTATE 23505) to 409; connection failures and the like are 500
if (err && typeof err === "object" && "code" in err && err.code === "23505") {
return new Response("slug already exists", { status: 409 });
}
console.error("createPost failed", err);
return new Response("internal error", { status: 500 });
}
return new Response(null, {
status: 303,
headers: { location: `/posts/${slug}` },
});
},
});
export default define.page<typeof handlers>(function NewPost() {
return (
<main>
<h1>New post</h1>
<form method="post">
<input name="slug" required pattern="[a-z0-9-]+" />
<input name="title" required />
<textarea name="body" required />
<button type="submit">Publish</button>
</form>
</main>
);
});
Protecting the admin area
A _middleware.ts in a subdirectory applies only to that directory's routes:
Browser forms cannot attach an Authorization header, so the middleware accepts both a cookie (after browser login) and a Bearer token (for API/curl):
import { define } from "@/utils.ts";
import { getCookies } from "jsr:@std/http/cookie";
const token = Deno.env.get("ADMIN_TOKEN");
export default define.middleware((ctx) => {
if (new URL(ctx.req.url).pathname === "/admin/login") return ctx.next();
const bearer = !!token && ctx.req.headers.get("authorization") === `Bearer ${token}`;
const cookie = !!token && getCookies(ctx.req.headers)["admin_token"] === token;
if (!bearer && !cookie) {
return new Response("Unauthorized", { status: 401 });
}
return ctx.next();
});
The login page verifies the password and sets an HttpOnly; SameSite=Lax cookie — with SameSite=Lax, cross-site POSTs do not carry the cookie, which is the CSRF floor for this placeholder scheme:
import { define } from "@/utils.ts";
export const handlers = define.handlers({
async POST(ctx) {
const form = await ctx.req.formData();
const token = Deno.env.get("ADMIN_TOKEN");
if (!token || form.get("token") !== token) {
return new Response("Unauthorized", { status: 401 });
}
return new Response(null, {
status: 303,
headers: {
location: "/admin/posts",
"set-cookie": `admin_token=${token}; Path=/admin; HttpOnly; SameSite=Lax`,
},
});
},
});
export default define.page<typeof handlers>(function Login() {
return (
<main>
<h1>Admin login</h1>
<form method="post">
<input name="token" type="password" required />
<button type="submit">Log in</button>
</form>
</main>
);
});
Milestone 2: Markdown, drafts, images, search
- Markdown: render in server components (see the official Rendering Markdown example); never create an island just for rendering.
- Drafts: the
publishedcolumn is already in the schema; filter withwhere(eq(posts.published, true))in list and detail, and add publish/unpublish actions in admin. - Images: small sites can use
static/; user uploads go through a separate protected endpoint with MIME and size validation (see the validation approach in the image API tutorial). - Search: start with a
routes/search.tsxpage reading query params and matching title/body via Drizzle'silike; move to PostgreSQL full-text indexes when volume demands it. - Editor: only
islands/PostEditor.tsx(preview, keyboard shortcuts) hydrates. Post bodies, navigation, and lists stay server components.
Test strategy
- Data layer: run
deno testagainst a dedicated test database, covering slug-constraint conflicts and draft filtering. - Handlers: Fresh's official testing pattern mounts route handlers on an in-memory
Appand drives them with WebRequestobjects:
import { App } from "fresh";
import { handlers } from "./posts.tsx";
Deno.test("POST rejects invalid slug", async () => {
const handler = new App().post("/admin/posts", handlers.POST).handler();
const form = new FormData();
form.set("slug", "Bad Slug!");
form.set("title", "t");
form.set("body", "b");
const res = await handler(
new Request("http://localhost/admin/posts", { method: "POST", body: form }),
);
if (res.status !== 400) throw new Error(`expected 400, got ${res.status}`);
});
- No-JS acceptance: walk list → detail → create with JavaScript disabled (or plain
curl).
Deployment
Fresh deploys to the new Deno Deploy: after attaching PostgreSQL, the platform injects DATABASE_URL, PGHOST, and friends, and migrations can run as a pre-deploy command before a revision receives traffic — which is exactly what "migrations are a separate release step" means. Details in Databases, cron, and timelines. Run deno task check and the full test suite before deploying.
Acceptance checklist
- List and detail pages readable and the create form submittable with JavaScript disabled
- Duplicate slugs rejected by the database unique constraint with a 409 response
- No component besides the PostEditor island ships JavaScript to the browser
- SQL from drizzle-kit generate is reviewed; migrate runs as a separate release step
- Unauthenticated requests to admin routes return 401
- deno fmt --check, deno lint, deno task check, and deno test all pass
Official references: Fresh Getting started, Fresh Forms, Fresh Testing, Deno + Drizzle tutorial, Drizzle with PostgreSQL.