🚀 Pocket Is Shutting Down – Here’s How I Cloned It with Astro & Cloudflare (Free!)

5/24/2025

Fast-lane:

  1. Export Pocket ➜ getpocket.com/export.
  2. Run node tools/pocket-csv-to-kv.js pocket.csv to convert CSV to KV bulk format.
  3. 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

LayerTechReason
Front-endAstro + TailwindFast HTML, zero JS by default
StorageCloudflare KVEdge-local key/value, free tier
APIsPages Functions (/api/save /api/list)10 ms latency
ScraperCheerio in FunctionTitle, description, og:image, favicon
CaptureBookmarkletOne-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

  1. Create Pages project → connect Git repo.
  2. Functions → KV: click “Add binding” → name BOOKMARKS.
  3. Environment variable: API_TOKEN = 32-char random hex (openssl rand -hex 16).
  4. 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 📥

  1. Export from Pocket 👉 getpocket.com/export (HTML or CSV).
  2. 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

FeatureOne-liner
Full-text searchFuse.js client-side on fetched JSON
Tag filter chips?tag=reading via list.ts query
Offline PWAworkbox injectManifest in Astro
Highlights syncStore 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! 🚀