iTranslated by AI
Implementing a Scrap Feature on My Blog with Cloudflare Workers, D1, and Access: Challenges and Tips
In my previous article, I summarized the basic architecture using Cloudflare Pages, Workers, D1, and Access.
This time, I will focus on the implementation. I will organize the authentication, display control, SSR, and deployment aspects of the Scraps feature, while highlighting the pitfalls I encountered.
I have implemented a feature similar to Zenn's "Scraps" on my own blog. While I adopted some of the UI elements, I prioritized the operational requirement that "only I need to write content."
Blog Architecture
- The main blog uses Astro + Cloudflare Pages, while the Scraps API is powered by Cloudflare Workers (D1).
- While anyone can view the Scraps, functions such as creating new entries, editing, closing/re-opening, and editing/deleting threads are restricted to "admin login" sessions only.
- Authentication is handled on the front end by storing
localStorage.scrap_admin_secretand verifying it against theADMIN_TOKENon the Worker side.
1. Management UI Display Control (Hide buttons when not logged in)


Admin links were visible even when not logged in
In my initial implementation, links like "Write new scrap" on /scraps or "Edit" on detail pages were visible even when not logged in. Although the API returned 401 upon request, it remained a state where the interface was visible but non-functional, which induced unnecessary attempts.
Render links only when conditions are met
I implemented a client-side check to determine whether to display the management UI, and if not, the buttons are not rendered at all. The "New" and "Edit" links only appear when PUBLIC_SCRAP_ADMIN_ENABLED is true and localStorage.scrap_admin_secret contains a value.
The management UI is enabled when PUBLIC_SCRAP_ADMIN_ENABLED === "true".
Creating Components
I wrapped the links in React and inserted the logic mentioned above directly. This replaces the static {isAdmin && ...} checks that were previously in index.astro and [slug].astro.
export function ScrapNewScrapLink() {
const adminEnabled = import.meta.env.PUBLIC_SCRAP_ADMIN_ENABLED === "true";
const [isAdmin, setIsAdmin] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
const secret = localStorage.getItem("scrap_admin_secret") ?? "";
setIsAdmin(!!secret);
}, []);
if (!adminEnabled || !isAdmin) return null;
return <a href="/scraps/admin">Create a new scrap</a>;
}
export function ScrapEditLink({ slug }: { slug: string }) {
const adminEnabled = import.meta.env.PUBLIC_SCRAP_ADMIN_ENABLED === "true";
const [isAdmin, setIsAdmin] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
const secret = localStorage.getItem("scrap_admin_secret") ?? "";
setIsAdmin(!!secret);
}, []);
if (!adminEnabled || !isAdmin) return null;
return <a href={`/scraps/${slug}/edit`}>Edit ✎</a>;
}
Buttons are no longer rendered when logged out. While this is the kind of processing that "should ideally be done server-side," it was sufficient for my requirement of simply hiding my personal management UI.
2. Thread updates failing (Mismatch between D1 schema and implementation)
"Update failed" when editing a thread
When attempting to update a thread, the screen displayed "Update failed." Checking the Worker logs, I suspected an SQL-related error, but the root cause was not visible from the frontend.
Mismatch between schema and implementation
Tracing the cause revealed a discrepancy in the Worker implementation. The PUT/DELETE routes were defined twice, and the older route was attempting to update updated_at, but the schema.sql did not have an updated_at column.
The background for this issue was that I had added implementation while requesting requirements from an AI. Rather than designing the schema and routes entirely before starting, appending them prompt-by-prompt led to leftover old SQL and scenarios where either the table definition or the implementation would get ahead of the other.
Points of concern
- PUT/DELETE routes were double-defined.
- The SQL updating
updated_atdid not match the schema definition.
// NG SQL (before deletion)
UPDATE scrap_entries
SET content = ?, updated_at = datetime('now')
...
Fixing the routes and SQL
I narrowed my tasks down to two points. For routes, I stopped the duplication of PUT/DELETE and unified them. For SQL, I ensured that no columns not present in schema.sql (like updated_at) were touched.
The following code shows the rewritten PUT handler for thread updates.
app.put("/scraps/:slug/entries/:id", async (c) => {
const res = requireAdmin(c);
if (res) return res;
const slug = c.req.param("slug");
const id = Number(c.req.param("id"));
const body = await c.req.json<{ content: string }>();
if (!body.content?.trim()) {
return c.json({ error: "content is required" }, 400);
}
const { meta } = await c.env.DB.prepare(
`UPDATE scrap_entries
SET content = ?
WHERE id = ?
AND scrap_id = (SELECT id FROM scraps WHERE slug = ?)`,
)
.bind(body.content, id, slug)
.run();
if (meta.changes === 0) return c.notFound();
return c.json({ ok: true });
});
With this fix, editing operations now succeed consistently. Since D1 is essentially SQLite, the "just add columns as I go" approach quickly breaks things, so it is easier to write at least a minimal ER diagram first.
3. Contents not updating when returning to the details page after editing (Incompatibility with SSG)
When I opened a scrap at /scraps/[slug], edited it, and saved it, returning to the details page showed the content as it was during the build. Even though I intended for it to be updated on the screen, the displayed HTML seemed not to have caught up.
Why is it "old even after editing"?
/scraps/[slug] was designed to generate static HTML at build time using getStaticPaths and getStatic. Even if the DB is updated via API, files that have already been distributed are not automatically replaced, so the combination of "editing an SSG page via API" did not work correctly as-is.
Dealing with it (Client-side fetching → SSR)
The diagram below shows the order in which I added where to fetch the latest data. In ①, the built HTML alone is out of sync with the DB. In ②, hitting the API after mounting makes the state on the screen close to the latest version, but routes that are not in getStaticPaths remain as 404 because the route itself does not exist. By turning /scraps/[slug].astro into SSR in ③, the server fetches on every request and embeds it into the HTML, allowing new slugs to be opened as well.
First, I prepared ScrapDetailContent and implemented a way to overwrite the state with the latest scrap and entries by hitting GET /scraps/:slug at mount time, following the initial display provided by Astro. While the experience is much better, the problem remains that slugs that did not exist at build time have no routes and result in 404s when followed from the index.
Therefore, I switched /scraps/[slug].astro to SSR. I covered how to set output in Astro 5, the Cloudflare adapter, and the prerender = false prerequisite in the previous architecture article, so I won't repeat it here.
In this article, I will show [slug].astro which sets export const prerender = false only for this route, and fetches scrap and entries from the API on every request. I will also include excerpts of the astro.config.mjs and dependencies required to enable the same SSR.
---
// scraps/[slug].astro
import Layout from "../../layouts/Layout.astro";
import { ScrapEditLink } from "../../components/ScrapEditLink";
import { ScrapDetailContent } from "../../components/ScrapDetailContent";
export const prerender = false;
const slug = Astro.params.slug;
if (!slug) {
return Astro.redirect("/404");
}
const apiBase =
import.meta.env.PUBLIC_SCRAP_API_BASE ?? "https://api.redamoon.net";
const response = await fetch(`${apiBase}/scraps/${slug}`);
if (!response.ok) {
return Astro.redirect("/404");
}
const data = await response.json();
const scrap = data?.scrap;
const entries = (data?.entries ?? []) as any[];
if (!scrap) {
return Astro.redirect("/404");
}
---
<Layout title={scrap.title}>
<main class="px-4 py-8 space-y-4">
<!-- Back button and edit link -->
<ScrapEditLink slug={slug} client:load />
<ScrapDetailContent
client:load
slug={slug}
initialScrap={scrap}
initialEntries={entries}
/>
</main>
</Layout>
With this change, newly created scraps can be opened from the index, and the latest content is always displayed when returning to the details page after editing.
astro.config.mjs and dependencies (excerpt)
Writing prerender = false in [slug].astro is not enough; you must specify the Cloudflare adapter for the entire project. I do not specify output, and I also do not write output: "hybrid" (which was deprecated in Astro 5 and will cause an option has been removed error).
// astro.config.mjs
import { defineConfig } from "astro/config";
import tailwind from "@astrojs/tailwind";
import react from "@astrojs/react";
import cloudflare from "@astrojs/cloudflare";
import sitemap from "@astrojs/sitemap";
import { SITE } from "./src/config";
export default defineConfig({
site: SITE.website,
adapter: cloudflare(),
integrations: [
tailwind({ applyBaseStyles: false }),
react(),
sitemap(),
],
// Do not specify output (in Astro 5, static is equivalent to hybrid)
});
I have included @astrojs/cloudflare (12.x series for Astro 5) in the package.json dependencies.
"dependencies": {
"@astrojs/check": "^0.9.6",
"@astrojs/cloudflare": "^12.6.13",
"@astrojs/rss": "^4.0.15"
}
4. "Create New Scrap" returns 401 in production (Conflict between Secret and vars)
Locally, POST /scraps worked fine, but performing the same action from the production frontend returned only 401 Unauthorized. Checking the request in developer tools showed that Authorization: Bearer ... was included, so it was clear that the issue was not a missing header, but that the value did not match the ADMIN_TOKEN expected by the Worker.
Cause: Conflict between vars and Secret, and mismatched input values
While the Worker was comparing against ADMIN_TOKEN, the explanation on the login screen was ambiguous, and there was a possibility that a different password was being entered than the value set in Secret. In addition, if ADMIN_TOKEN = "change-me" is written in [vars] in wrangler.toml, it fails with "Binding name already in use" when trying to register a Secret with the same name using wrangler secret put ADMIN_TOKEN. Since I was trying to use the same name for both vars and Secret, the structure was such that the value from vars was taking effect in production, conflicting with the intended Secret.
Solution: Centralize in Secret and verify via /auth/verify
I removed ADMIN_TOKEN from wrangler.toml and decided to operate only with Secret. To verify if the token is correct immediately after login, I added GET /auth/verify to the Workers. In the frontend's ScrapLoginForm, after saving the entered password to localStorage.scrap_admin_secret, it now hits this endpoint before proceeding to the admin screen.
wrangler.toml looks like this:
name = "scrap-api"
main = "workers/scrap-api/src/index.ts"
compatibility_date = "2025-01-01"
[[d1_databases]]
binding = "DB"
database_name = "redamoon-scrap"
database_id = "..."
# ADMIN_TOKEN is set via Secret (do not write to vars)
# Local/Production: wrangler secret put ADMIN_TOKEN
For both production and local, I register the Secret via CLI.
wrangler secret put ADMIN_TOKEN
/auth/verify is a route that simply passes through requireAdmin. If it passes, it returns { ok: true }, and if the token is incorrect, it returns 401.
/** For token verification immediately after login */
app.get("/auth/verify", async (c) => {
const res = requireAdmin(c);
if (res) return res;
return c.json({ ok: true });
});
In the ScrapLoginForm submit handler, I save it to localStorage first, then call /auth/verify, and if it fails, I clear the key and display an error.
const apiBase =
import.meta.env.PUBLIC_SCRAP_API_BASE ?? "https://api.redamoon.net";
export function ScrapLoginForm() {
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
const token = password.trim();
if (!token) return;
setLoading(true);
setError(null);
try {
localStorage.setItem("scrap_admin_secret", token);
const res = await fetch(`${apiBase}/auth/verify`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
localStorage.removeItem("scrap_admin_secret");
setError(
"This password does not match the API's ADMIN_TOKEN. If in production, please enter the exact same value as the Worker's Secret (wrangler secret put ADMIN_TOKEN).",
);
setLoading(false);
return;
}
window.location.href = "/scraps/admin";
} catch {
localStorage.removeItem("scrap_admin_secret");
setError(
"Cannot connect to API. Please check PUBLIC_SCRAP_API_BASE and the Worker URL.",
);
setLoading(false);
}
};
}
With this, users are blocked from logging in unless the Secret ADMIN_TOKEN matches perfectly. Once matched, it redirects to /scraps/admin, and subsequent POST/PUT/DELETE operations are now stable.
Conclusion
I ventured into using Cloudflare Pages, Workers, D1, and Access because I wanted to include features like Zenn's scraps on my blog. It served as a good study for personal development as it deepened my understanding of not just implementation, but also design and specification reading.
For balancing static generation and API editing, I found that offloading only the problematic pages to SSR with prerender = false worked best. Since giving the same name to [vars] and Secret causes conflicts, I have centralized the management token in Secret. Authentication via Cloudflare Access and a simple token was sufficient; keeping it lightweight made progress easier. The admin interface was a bit tricky, but it was great to be able to finalize the authentication logic while consulting with an AI.
D1 is SQLite, so any discrepancy between the schema and SQL results directly in trouble. It seems better to keep at least a minimal ER diagram on hand yourself. I was not accustomed to SQLite, so tracking down where the discrepancies occurred was difficult, but I deepened my understanding by consulting an AI.
I think reading this alongside the Architecture Edition (basic configuration of Pages, Workers, D1, and Access) that I summarized as a prerequisite will help connect the big picture with the implementation pitfalls.
Discussion