🚀 Pocket Is Shutting Down – Here’s How I Cloned It with Astro & Cloudflare (Free!)
5/24/2025
Fast-lane:
- Export Pocket ➜ getpocket.com/export.
- Run
node tools/pocket-csv-to-kv.js pocket.csv
to convert CSV to KV bulk format.- Deploy to Cloudflare Pages → done.
Live demo of the final UI: https://anuj-portfolio-8sh.pages.dev/reads/.
🧐 Why replace Pocket?
- Pocket is decommissioned (sun-set notice landed in May ’25).
- I wanted no new SaaS subscription and full data ownership.
- Free Cloudflare stack = global edge + 1 GiB KV storage at ₹0/month.
🏗️ Architecture
Layer | Tech | Reason |
---|---|---|
Front-end | Astro + Tailwind | Fast HTML, zero JS by default |
Storage | Cloudflare KV | Edge-local key/value, free tier |
APIs | Pages Functions (/api/save /api/list ) | 10 ms latency |
Scraper | Cheerio in Function | Title, description, og:image, favicon |
Capture | Bookmarklet | One-tap save on any browser |
browser bookmarklet -> POST /api/save -> KV put
reads.astro (static HTML) <- GET /api/list <- KV list
🔑 Prerequisites
- Node ≥ 18
- Cloudflare account
- GitHub repo (or copy-paste)
1️⃣ Initialise Astro project
npm create astro@latest pocket-clone
cd pocket-clone
npm i -D tailwindcss @tailwindcss/typography nanoid cheerio
Enable Tailwind in astro.config.mjs
:
import tailwindcss from '@tailwindcss/vite';
export default { vite: { plugins: [tailwindcss()] } };
2️⃣ Cloudflare Pages & KV
- Create Pages project → connect Git repo.
- Functions → KV: click “Add binding” → name BOOKMARKS.
- Environment variable:
API_TOKEN
= 32-char random hex (openssl rand -hex 16
). - Root
wrangler.toml
:
name = "astro-pocket"
compatibility_date = "2025-05-24"
pages_build_output_dir = "./dist"
[[kv_namespaces]]
binding = "BOOKMARKS"
id = "YOUR_NAMESPACE_ID"
3️⃣ Edge Functions
/functions/api/save.ts
import { nanoid } from 'nanoid';
import * as cheerio from 'cheerio';
async function meta(url: string) {
const html = await (
await fetch(url, { redirect: 'follow', cf: { cacheTtl: 3600 } })
).text();
const $ = cheerio.load(html);
const abs = (s?: string) =>
s && (s.startsWith('http') ? s : new URL(s, url).href);
const domain = new URL(url).hostname;
return {
title:
$('meta[property="og:title"]').attr('content') ||
$('title').text() ||
url,
desc: $('meta[property="og:description"]').attr('content') || '',
img: abs($('meta[property="og:image"]').attr('content')),
fav:
abs($('link[rel~="icon"]').attr('href')) ||
`https://icons.duckduckgo.com/ip3/${domain}.ico`,
site: $('meta[property="og:site_name"]').attr('content') || domain,
};
}
export const onRequestPost: PagesFunction = async ({ request, env }) => {
const u = new URL(request.url);
if (u.searchParams.get('token') !== env.API_TOKEN)
return new Response('🚫', { status: 403 });
const { url } = await request.json();
const data = await meta(url);
await env.BOOKMARKS.put(
nanoid(8),
JSON.stringify({ ...data, url, ts: Date.now(), status: 'unread', tags: [] })
);
return new Response('ok', {
headers: { 'Access-Control-Allow-Origin': '*' },
});
};
/functions/api/list.ts
export const onRequestGet: PagesFunction = async ({ request, env }) => {
if (new URL(request.url).searchParams.get('token') !== env.API_TOKEN)
return new Response('403', { status: 403 });
const list = await env.BOOKMARKS.list();
const rows = await Promise.all(
list.keys.map(k => env.BOOKMARKS.get(k.name, 'json'))
);
return new Response(JSON.stringify(rows.sort((a, b) => b.ts - a.ts)), {
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
},
});
};
tools/pocket-csv-to-kv.js
import fs from 'fs/promises';
import { nanoid } from 'nanoid/non-secure';
import * as cheerio from 'cheerio';
if (!process.argv[2]) {
console.error('usage: node tools/pocket-csv-to-kv.mjs pocket.csv');
process.exit(1);
}
const raw = await fs.readFile(process.argv[2], 'utf8');
const rows = raw
.trim()
.split('\n')
.slice(1) // skip header
.map(r => r.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/g));
async function scrapeMeta(rawUrl) {
const url = rawUrl.startsWith('http') ? rawUrl : `https://${rawUrl}`;
try {
const html = await (
await fetch(url, { redirect: 'follow', cf: { cacheTtl: 3600 } })
).text();
const $ = cheerio.load(html);
const abs = src =>
src && src.startsWith('http')
? src
: src
? new URL(src, url).href
: undefined;
const title =
$('meta[property="og:title"]').attr('content') ||
$('title').text().trim() ||
url;
const desc =
$('meta[property="og:description"]').attr('content') ||
$('meta[name="description"]').attr('content') ||
'';
const img = abs($('meta[property="og:image"]').attr('content'));
const fav =
abs($('link[rel~="icon"]').attr('href')) ||
`https://icons.duckduckgo.com/ip3/${new URL(url).hostname}.ico`;
const site =
$('meta[property="og:site_name"]').attr('content') ||
new URL(url).hostname;
return { title, desc, img, fav, site };
} catch {
const domain = new URL(url).hostname;
return {
title: url,
desc: '',
img: undefined,
fav: `https://icons.duckduckgo.com/ip3/${domain}.ico`,
site: domain,
};
}
}
const out = [];
for (const [csvTitle, url, ts, tagStr, status] of rows) {
console.log('scraping', url);
const meta = await scrapeMeta(url);
out.push({
key: nanoid(8),
value: JSON.stringify({
id: nanoid(8),
url,
tags: tagStr ? tagStr.split(';') : [],
status: status || 'unread',
ts: Number(ts) * 1000,
...meta,
}),
});
}
await fs.writeFile('pocket-kv.json', JSON.stringify(out));
console.log('✅ pocket-kv.json ready –', out.length, 'records');
4️⃣ Bookmarklet 🔖
javascript: (async () => {
await fetch('https://YOURDOMAIN.com/api/save?token=API_TOKEN', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: location.href }),
});
alert('Saved!');
})();
Drag to the bookmarks bar; on mobile edit any bookmark and replace URL with the code.
5️⃣ Import old Pocket CSV 📥
- Export from Pocket 👉 getpocket.com/export (HTML or CSV).
- Convert + scrape:
node tools/pocket-csv-to-kv.js pocket.csv
wrangler kv bulk put pocket-kv.json --binding BOOKMARKS --remote
tools/pocket-csv-to-kv.js
parses each row, fetches <title>
+ meta description, favicons, and writes pocket-kv.json
in the KV bulk format.
6️⃣ Front-end (Astro) ✨
src/pages/reads.astro
---
const API = 'https://YOURDOMAIN.com/api/list?token=API_TOKEN';
const items = (await (await fetch(API)).json()).sort((a, b) => b.ts - a.ts);
---
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{
items.map(b => (
<div class="group overflow-hidden rounded-xl bg-white shadow dark:bg-neutral-800">
{b.img && <img src={b.img} class="h-40 w-full object-cover" alt="" />}
<div class="flex h-full flex-col p-4">
<div class="mb-2 flex items-center gap-2 text-sm text-neutral-500">
<img src={b.fav} class="h-4 w-4" /> {b.site}
</div>
<a
href={b.url}
target="_blank"
class="line-clamp-2 font-semibold text-blue-600 hover:underline dark:text-blue-400"
>
{b.title}
</a>
{b.desc && <p class="my-2 line-clamp-3 text-sm">{b.desc}</p>}
<p class="mt-auto text-xs text-neutral-500">
{new Date(b.ts).toLocaleDateString()}
</p>
</div>
</div>
))
}
</div>
Tailwind’s line-clamp
keeps text tidy; cards are fully responsive.
7️⃣ Deploy 🛫
git add .
git commit -m "self-hosted Pocket clone"
git push origin main # Cloudflare Pages auto-deploy
Done! Preview at https://anuj-portfolio-8sh.pages.dev/reads/.
🗺️ Roadmap ideas
Feature | One-liner |
---|---|
Full-text search | Fuse.js client-side on fetched JSON |
Tag filter chips | ?tag=reading via list.ts query |
Offline PWA | workbox injectManifest in Astro |
Highlights sync | Store notes in KV, use contenteditable |
🔚 Wrap-up
Pocket’s shutdown doesn’t mean losing your “read later” workflow. With Astro + Cloudflare Pages + KV, you:
- keep every link private
- host for free
- add features at will
Happy building! 🚀