Back
other Jul 23, 2025

How to Add a Bookmark Content Collection in Astro

A beginner-friendly tutorial on creating a bookmark content collection in Astro.

by Anurag Bhandari anurock.dev 1,734 words
View original

/ 9 min read

,

Table of Contents

/**

A beginner-friendly tutorial on creating a bookmark content collection in Astro.

*/

My favorite bookmarking service Pocket shut down earlier this month. I’m inclined to think whatever bookmarking service I use gets cursed. First Digg, then Delicious, now Pocket.

My first instinct was to find the next one (to curse;) ). I found Instapaper, created an account, and played around a bit. Then came the sudden realization: I didn’t need another service I could not control. I could simply build a lightweight bookmarking functionality on my very own dev blog!

With an AI assistant at my disposal, I wouldn’t even need a whole weekend to build it out. I ended up building it in less than 3 hours. In this tutorial, I’m laying out the high level steps to save you from falling into a rabbit hole of an overthinking AI.

Astro’s content collections feature is a powerful way to organize and manage your site’s content. If the official documentation feels overwhelming, this guide will walk you through the process step-by-step using a practical example: creating a bookmark content collection to share online articles.

First, ensure you have the @astrojs/content package installed. It’ll likely be already installed. If not, run the following command in your terminal:

npm install @astrojs/content

Step 2: Configure your Astro project

Open your astro.config.mjs file and add the content integration: (if it doesn’t exist already)

import { defineConfig } from 'astro/config';
import content from '@astrojs/content';

export default defineConfig({
  integrations: [content()],
});

Open your content.config.ts file and define your new content collection at the bottom:

Inside this folder, create a schema file to define the structure of your bookmark content:

const bookmark = defineCollection({
  loader: glob({ base: "./src/content/bookmark", pattern: "**/*.{md,mdx}" }),
  schema: baseSchema.extend({
    url: z.string().url(),
    excerpt: z.string(),
    readDate: z
      .string()
      .datetime({ offset: true })
      .transform((val) => new Date(val)),
  }),
});

export const collections = { post, note, bookmark }; // add bookmark to the end of your existing export

Handy functions if you intend to fetch bookmark data in multiple places.

import { type CollectionEntry, getCollection } from "astro:content";

/** get all bookmarks */
export async function getAllBookmarks(): Promise<CollectionEntry<"bookmark">[]> {
    return await getCollection("bookmark");
}

/** groups bookmarks by year (based on readDate), using the year as the key */
export function groupBookmarksByYear(bookmarks: CollectionEntry<"bookmark">[]) {
    return bookmarks.reduce<Record<string, CollectionEntry<"bookmark">[]>>((acc, bookmark) => {
        const year = bookmark.data.readDate.getFullYear();
        if (!acc[year]) {
            acc[year] = [];
        }
        acc[year]?.push(bookmark);
        return acc;
    }, {});
}

Skip if you don’t intend to reuse how you display bookmarks. Here’s my Bookmark component that I use on home and bookmarks pages:

---
import { type CollectionEntry, render } from "astro:content";
import FormattedDate from "@/components/FormattedDate.astro";
import type { HTMLTag, Polymorphic } from "astro/types";

type Props<Tag extends HTMLTag> = Polymorphic<{ as: Tag }> & {
  bookmark: CollectionEntry<"bookmark">;
  isPreview?: boolean | undefined;
};

const { as: Tag = "div", bookmark, isPreview = false } = Astro.props;
const { Content } = await render(bookmark);
---

<article
  class:list={[isPreview && "inline-grid rounded-md bg-blue-50 px-4 py-3 dark:bg-[rgb(33,35,38)]"]}
  data-pagefind-body={isPreview ? false : true}
>
  <Tag class="title" class:list={{ "text-base": isPreview }}>
    {
      isPreview ? (
        <a class="cactus-link" href={\`/bookmarks/${bookmark.id}/\`}>
          {bookmark.data.title}
        </a>
      ) : (
        <>{bookmark.data.title}</>
      )
    }
  </Tag>
  <div class="mb-2 flex flex-wrap items-center gap-2 text-sm">
    <a
      href={bookmark.data.url}
      target="_blank"
      rel="noopener noreferrer"
      class="text-accent hover:underline"
    >
      Read Original →
    </a>
    <FormattedDate
      dateTimeOptions={{
        hour: "2-digit",
        minute: "2-digit",
        year: "2-digit",
        month: "2-digit",
        day: "2-digit",
      }}
      date={bookmark.data.readDate}
    />
  </div>
  {
    !isPreview && (
      <p class="mb-3 text-sm text-gray-600 dark:text-gray-400">{bookmark.data.excerpt}</p>
    )
  }
  {
    !isPreview && (
      <div class="prose prose-sm prose-cactus mt-4 max-w-none [&>p:last-of-type]:mb-0">
        <blockquote>
          <Content />
        </blockquote>
      </div>
    )
  }
</article>

As with the previous step, your markup may be wildly different from mine. Pick the meaty parts and adapt accordingly. As you’ll notice, my site uses Tailwind CSS.

---
import { type CollectionEntry } from "astro:content";
import Pagination from "@/components/Paginator.astro";
import Bookmark from "@/components/bookmark/Bookmark.astro";
import { getAllBookmarks } from "@/data/bookmark";
import PageLayout from "@/layouts/Base.astro";
import type { GetStaticPaths, Page } from "astro";
import { Icon } from "astro-icon/components";

export const getStaticPaths = (async ({ paginate }) => {
  const MAX_BOOKMARKS_PER_PAGE = 10;
  const allBookmarks = await getAllBookmarks();
  // Sort by readDate in reverse chronological order
  const sortedBookmarks = allBookmarks.sort(
    (a, b) => b.data.readDate.getTime() - a.data.readDate.getTime(),
  );
  return paginate(sortedBookmarks, { pageSize: MAX_BOOKMARKS_PER_PAGE });
}) satisfies GetStaticPaths;

interface Props {
  page: Page<CollectionEntry<"bookmark">>;
}

const { page } = Astro.props;

const meta = {
  description: "Read my collection of bookmarks",
  title: "Bookmarks",
};

const paginationProps = {
  ...(page.url.prev && {
    prevUrl: {
      text: "← Previous Page",
      url: page.url.prev,
    },
  }),
  ...(page.url.next && {
    nextUrl: {
      text: "Next Page →",
      url: page.url.next,
    },
  }),
};
---

<PageLayout meta={meta}>
  <section>
    <h1 class="title mb-6 flex items-center gap-3">
      Bookmarks
      <a class="text-accent" href="/bookmarks/rss.xml" target="_blank">
        <span class="sr-only">RSS feed</span>
        <Icon aria-hidden="true" class="h-6 w-6" focusable="false" name="mdi:rss" />
      </a>
    </h1>
    <ul class="mt-6 space-y-8 text-start">
      {
        page.data.map((bookmark) => (
          <li>
            <Bookmark bookmark={bookmark} as="h2" isPreview />
          </li>
        ))
      }
    </ul>
    <Pagination {...paginationProps} />
  </section>
</PageLayout>
---
import { getCollection } from "astro:content";
import Bookmark from "@/components/bookmark/Bookmark.astro";
import PageLayout from "@/layouts/Base.astro";
import type { GetStaticPaths, InferGetStaticPropsType } from "astro";

export const getStaticPaths = (async () => {
  const allBookmarks = await getCollection("bookmark");
  return allBookmarks.map((bookmark) => ({
    params: { slug: bookmark.id },
    props: { bookmark },
  }));
}) satisfies GetStaticPaths;

export type Props = InferGetStaticPropsType<typeof getStaticPaths>;

const { bookmark } = Astro.props;

const meta = {
  description: bookmark.data.excerpt,
  title: bookmark.data.title,
};
---

<PageLayout meta={meta}>
  <Bookmark as="h1" bookmark={bookmark} />
</PageLayout>

Step 8: Integration

Finally, link the pages you’ve created above in your navigation and on your home page.

Optionally, add an RSS feed for your bookmarks:

import { getAllBookmarks } from "@/data/bookmark";
import { siteConfig } from "@/site.config";
import rss from "@astrojs/rss";

export const GET = async () => {
    const bookmarks = await getAllBookmarks();
    const sortedBookmarks = bookmarks.sort((a, b) =>
        b.data.readDate.getTime() - a.data.readDate.getTime()
    );

    return rss({
        title: \`${siteConfig.title} - Bookmarks\`,
        description: "My collection of bookmarks",
        site: import.meta.env.SITE,
        items: sortedBookmarks.map((bookmark) => ({
            title: bookmark.data.title,
            description: bookmark.data.excerpt,
            pubDate: bookmark.data.readDate,
            link: \`bookmarks/${bookmark.id}/\`,
        })),
    });
};

Link it in your page head:

<link href="/bookmarks/rss.xml" title="Bookmarks" rel="alternate" type="application/rss+xml" />

Add your bookmark content files in the collection folder. Each file should follow the schema you defined. For example:

---
title: "Your Career Needs a Vision, Not More Goals"
url: "https://alifeengineered.substack.com/p/your-career-needs-a-vision-not-more"
excerpt: "Why you can hit every target and still feel lost. A simple 3-part framework for an intentional life."
readDate: "2025-07-23T10:46:54.789Z"
---

Deep, deep thoughts about vision, goals, and how people commonly confuse them. What beauty of an article! The central concept is similar to Cal Newport's lifestyle-centric planning, but it's nice to revisit it in the context of a software engineer.

Result of the create-bookmark npm script

Make your life easy by having a script write bookmark markdown for you. The following script takes care of extracting title, description, etc. from a URL to create a new markdown file complete with frontmatter.

Usage:

npm run create-bookmark https://blog.isquaredsoftware.com/2025/06/react-community-2025/ "My complex thoughts about the topic here"

The second param (thoughts/commentary) is optional.

const fs = require('fs');
const path = require('path');
const { JSDOM } = require('jsdom');

const FILE_NAME = __filename || path.basename(__filename);
const DIR_NAME = path.dirname(FILE_NAME);

async function fetchPageMetadata(url) {
    try {
        const response = await fetch(url);
        if (!response.ok) {
            throw new Error(\`HTTP error! status: ${response.status}\`);
        }

        const html = await response.text();
        const dom = new JSDOM(html);
        const document = dom.window.document;

        // Extract metadata
        const title =
            document.querySelector('meta[property="og:title"]')?.content ||
            document.querySelector('meta[name="twitter:title"]')?.content ||
            document.querySelector('title')?.textContent ||
            'Untitled';

        const description =
            document.querySelector('meta[property="og:description"]')?.content ||
            document.querySelector('meta[name="twitter:description"]')?.content ||
            document.querySelector('meta[name="description"]')?.content ||
            '';

        const author =
            document.querySelector('meta[name="author"]')?.content ||
            document.querySelector('meta[property="article:author"]')?.content ||
            '';

        const siteName =
            document.querySelector('meta[property="og:site_name"]')?.content ||
            new URL(url).hostname;

        return {
            title: title.trim(),
            description: description.trim(),
            author: author.trim(),
            siteName: siteName.trim()
        };
    } catch (error) {
        console.error('Error fetching metadata:', error.message);
        return {
            title: 'Untitled',
            description: '',
            author: '',
            siteName: new URL(url).hostname
        };
    }
}

function createSlug(title) {
    return title
        .toLowerCase()
        .replace(/[^\w\s-]/g, '') // Remove special characters
        .replace(/\s+/g, '-') // Replace spaces with hyphens
        .replace(/-+/g, '-') // Replace multiple hyphens with single
        .trim();
}

function generateFrontmatter(url, metadata, thoughts) {
    const now = new Date();
    const readDate = now.toISOString();

    // Clean up title and description for YAML
    const cleanTitle = metadata.title.replace(/"/g, '\\"');
    const cleanDescription = metadata.description.replace(/"/g, '\\"');

    const boilerplateThoughts = \`<!-- Add your notes about this bookmark here -->

${metadata.author ? \`**Author:** ${metadata.author}\` : ''}
${metadata.siteName ? \`**Source:** ${metadata.siteName}\` : ''}\`;
    const thoughtsToAppend = thoughts ?? boilerplateThoughts;

    return \`---
title: "${cleanTitle}"
url: "${url}"
excerpt: "${cleanDescription}"
readDate: "${readDate}"
---

${thoughtsToAppend}
\`;
}

async function createBookmark(url, thoughts) {
    try {
        // Validate URL
        new URL(url);

        console.log(\`Fetching metadata for: ${url}\`);
        const metadata = await fetchPageMetadata(url);

        // Create filename from title
        const slug = createSlug(metadata.title);
        const timestamp = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
        const filename = \`${timestamp}-${slug}.md\`;

        // Create bookmark content
        const content = generateFrontmatter(url, metadata, thoughts);

        // Ensure bookmark directory exists
        const bookmarkDir = path.join(DIR_NAME, '..', 'src', 'content', 'bookmark');
        if (!fs.existsSync(bookmarkDir)) {
            fs.mkdirSync(bookmarkDir, { recursive: true });
        }

        // Write file
        const filepath = path.join(bookmarkDir, filename);

        if (fs.existsSync(filepath)) {
            console.log(\`Warning: File ${filename} already exists. Overwriting...\`);
        }

        fs.writeFileSync(filepath, content);

        console.log(\`✅ Bookmark created successfully!\`);
        console.log(\`📁 File: ${filepath}\`);
        console.log(\`📝 Title: ${metadata.title}\`);
        console.log(\`🔗 URL: ${url}\`);

        return filepath;
    } catch (error) {
        console.error('❌ Error creating bookmark:', error.message);
        process.exit(1);
    }
}

// Main execution
const url = process.argv[2];
const thoughts = process.argv[3]; // optional

if (!url) {
    console.error('❌ Please provide a URL as an argument');
    console.log('Usage: npm run create-bookmark <url>');
    process.exit(1);
}

createBookmark(url, thoughts);

The Add Bookmark workflow in GitHub UI

A neat hack to have a UI to add new bookmarks. The npm script we created above is a prerequisite.

name: Add Bookmark

on:
  workflow_dispatch:
    inputs:
      url:
        description: "The URL of the bookmark"
        required: true
        type: string
      thoughts:
        description: "Your thoughts about the bookmark"
        required: false
        type: string

jobs:
  add-bookmark:
    runs-on: ubuntu-latest

    permissions:
      contents: write

    # Restrict workflow to specific users
    if: github.actor == 'anu-rock'

    steps:
      # Step 1: Checkout the repository
      - name: Checkout repository
        uses: actions/checkout@v3

      # Step 2: Set up Node.js
      - name: Set up Node.js
        uses: actions/setup-node@v3
        with:
          node-version: "22"

      # Step 3: Install dependencies
      - name: Install dependencies
        run: npm install

      # Step 4: Run the create-bookmark script
      - name: Create bookmark
        run: npm run create-bookmark "${{ github.event.inputs.url }}" "${{ github.event.inputs.thoughts }}"

      # Step 5: Commit and push changes
      - name: Commit and push changes
        run: |
          git config --global user.name "AnuRock"
          git config --global user.email "echo@anurock.dev"
          git add .
          git commit -m "Add bookmark"
          git push

Conclusion

That’s it! You’ve successfully added a bookmark content collection in Astro. This tutorial simplifies the process and provides a practical example. For more advanced features, refer to the official documentation.

Happy coding!