npm package: @k34a/blog
Description: Create and share articles with your audience.
Latest version: 0.0.21

# @k34a/blog

**A complete Articles/Blog Engine for Next.js + Supabase**
Easily fetch, search, filter, render, and display articles inside your application.
Includes backend utilities, frontend UI components, filtering, pagination, and SEO-ready article rendering out of the box.

---

## ✨ Features

-   **Article fetching & server-side rendering** using Supabase
-   **Search, filter & sort** via built-in query schema
-   **Article listing UI components** (cards, filters, pagination)
-   **Full article viewer** with banner, tags, metadata & Supabase-hosted images
-   **Reusable ArticleService** for all backend functionalities

---

## 📦 Installation

```bash
npm install @k34a/blog
```

### Required peer dependencies

```bash
npm install @mantine/core @tabler/icons-react react @supabase/supabase-js zod
```

| Package                 | Version  |
| ----------------------- | -------- |
| `@mantine/core`         | ≥ 8.0.0  |
| `@supabase/supabase-js` | ≥ 2.52.0 |
| `@tabler/icons-react`   | ≥ 3.0.0  |
| `react`                 | ≥ 19.1.0 |
| `zod`                   | >= 4.0.0 |

---

# ⚙️ Setup (DB)

Run the following command in your supabase SQL editor to setup a view.

```sql
create view article_with_tags as
select a.*, t.name as tag
from articles a
left join tag_articles ta on ta.article_id = a.id
left join tags t on t.id = ta.tag_id;
```

# ⚙️ Setup (Backend)

Create an instance of `ArticleService` anywhere in your backend/server code.
A common location is: `lib/articles.ts`.

```ts
import { supabaseAdmin } from '@/lib/db/supabase';
import { ArticleService } from '@k34a/blog';

export const articleService = new ArticleService(supabaseAdmin);
```

> This gives you access to all backend operations including listing, filtering, fetching by slug, resolving descriptions, etc.

---

# 📰 Creating an Article Card Component

Use the built-in types & image resolver:

```tsx
'use client';

import React from 'react';
import {
    Card,
    Text,
    Badge,
    Group,
    Button,
    Stack,
    Title,
    Divider,
} from '@mantine/core';
import { IconBrandWhatsapp, IconClock, IconTag } from '@tabler/icons-react';
import Image from '@/components/image';
import Link from 'next/link';
import { ngoDetails } from '@/config/config';
import {
    type ArticleDetailsForListing,
    resolveImageForArticle,
} from '@k34a/blog';

interface Props {
    article: ArticleDetailsForListing & { tags?: string[] };
}

export default function ArticleCard({ article }: Props) {
    const {
        id,
        title,
        description,
        slug,
        banner_image,
        created_at,
        tags = [],
    } = article;

    const articleLink = `/articles/${slug}`;
    const shareUrl = `${ngoDetails.contact.website}/articles/${slug}`;
    const shareText = encodeURIComponent(
        `Check out this article "${title}": ${shareUrl}`
    );
    const whatsappLink = `https://wa.me/?text=${shareText}`;

    const formattedDate = new Date(created_at).toLocaleDateString('en-IN', {
        day: 'numeric',
        month: 'short',
        year: 'numeric',
    });

    return (
        <Card shadow="sm" radius="md" p={0} withBorder maw={350}>
            {banner_image && (
                <Card.Section style={{ position: 'relative' }}>
                    <Link href={articleLink}>
                        <Image
                            src={resolveImageForArticle(
                                process.env.NEXT_PUBLIC_SUPABASE_HOSTNAME!,
                                id,
                                banner_image
                            )}
                            alt={`${title} banner`}
                            width={400}
                            height={220}
                            style={{
                                objectFit: 'cover',
                                width: '100%',
                                height: 220,
                            }}
                        />
                    </Link>
                </Card.Section>
            )}

            <Stack gap="xs" p="md">
                <Link href={articleLink}>
                    <Title size="lg" order={3} style={{ lineHeight: 1.2 }}>
                        {title}
                    </Title>
                </Link>

                <Group gap="xs" c="dimmed" mb={4}>
                    <IconClock size={14} />
                    <Text size="sm">{formattedDate}</Text>
                </Group>

                <Text size="sm" lineClamp={3}>
                    {description}
                </Text>

                {tags.length > 0 && (
                    <Group gap={6} mt={6}>
                        {tags.slice(0, 3).map((tag, idx) => (
                            <Badge
                                key={idx}
                                color="gray"
                                variant="light"
                                leftSection={<IconTag size={12} />}
                            >
                                {tag}
                            </Badge>
                        ))}
                    </Group>
                )}

                <Divider my="sm" />

                <Group grow>
                    <Button
                        component="a"
                        href={whatsappLink}
                        target="_blank"
                        rel="noopener noreferrer"
                        variant="outline"
                        color="green"
                        leftSection={<IconBrandWhatsapp size={16} />}
                        fullWidth
                        size="md"
                    >
                        Share
                    </Button>

                    <Button
                        component={Link}
                        href={articleLink}
                        variant="filled"
                        fullWidth
                        size="md"
                    >
                        Read More
                    </Button>
                </Group>
            </Stack>
        </Card>
    );
}
```

---

# 🔍 Creating a Search + Filter + Sort Bar

```tsx
'use client';

import {
    FilterSearchSortArticles,
    type FilterSearchSortArticlesProps,
} from '@k34a/blog';
import { usePathname, useRouter } from 'next/navigation';

type Props = Omit<FilterSearchSortArticlesProps, 'navigate'>;

export const FilterSearchSort = (props: Props) => {
    const router = useRouter();
    const pathname = usePathname();

    function navigate(query: string) {
        router.push(`${pathname}?${query}`);
    }

    return <FilterSearchSortArticles {...props} navigate={navigate} />;
};
```

---

# 📄 Pagination Control

```tsx
'use client';

import { Pagination, Group } from '@mantine/core';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';

interface PaginationControlsProps {
    total: number;
    currentPage: number;
    pageSize: number;
}

export default function PaginationControls({
    total,
    currentPage,
    pageSize,
}: PaginationControlsProps) {
    const router = useRouter();
    const pathname = usePathname();
    const searchParams = useSearchParams();

    const totalPages = Math.ceil(total / pageSize);

    if (totalPages <= 1) return null;

    const handlePageChange = (page: number) => {
        const params = new URLSearchParams(searchParams.toString());
        params.set('page', String(page - 1));
        router.push(`${pathname}?${params.toString()}`);
    };

    return (
        <Group justify="center" mt="xl">
            <Pagination
                total={totalPages}
                value={currentPage + 1}
                onChange={handlePageChange}
            />
        </Group>
    );
}
```

---

# 📝 Rendering a Single Article Page

```tsx
import { articleService } from '@/lib/db/articles';
import { Article } from '@k34a/blog';
import { Metadata } from 'next';
import { notFound } from 'next/navigation';

type PageProps = {
    params: Promise<{ slug: string }>;
};

export async function generateMetadata({
    params,
}: PageProps): Promise<Metadata> {
    const campaign = await articleService.getBySlug((await params).slug);

    if (!campaign) {
        return {
            title: 'Not Found',
            description: "The article you're looking for does not exist.",
        };
    }

    return {
        title: campaign.title,
        description: campaign.description,
    };
}

export default async function ArticlePage({ params }: PageProps) {
    const { slug } = await params;

    const article = await articleService.getBySlug(slug);
    if (!article) return notFound();

    const description = (await articleService.getDescription(article.id)) ?? '';

    return (
        <Article
            config={{
                supabaseHost: process.env.NEXT_PUBLIC_SUPABASE_HOSTNAME!,
            }}
            description={description}
            tags={article.tags}
            title={article.title}
            banner_image={article.banner_image ?? undefined}
            id={article.id}
        />
    );
}
```

---

# 📚 Rendering an Article Listing Page

```tsx
import ArticlesCard from '@/components/articles-listing/card';
import { articleQuerySchema } from '@k34a/blog';
import { parseQueryWithPerFieldDefaults } from '@/lib/utils/query-params';
import { Container, SimpleGrid, Stack, Text } from '@mantine/core';
import { IconSearchOff } from '@tabler/icons-react';
import { articleService } from '@/lib/db/articles';
import PaginationControls from '@/components/content-management/pagination-controls';
import { FilterSearchSort } from '@/components/articles-listing/filter-search-sort';

interface Props {
    searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}

export default async function Page(props: Props) {
    const searchParams = await props.searchParams;
    const params = parseQueryWithPerFieldDefaults(
        articleQuerySchema,
        searchParams
    );

    const data = await articleService.list(params);
    const tags = await articleService.getTagNames();

    return (
        <Container size="lg" py="xl">
            <FilterSearchSort {...params} availableTags={tags} />

            {data.items.length === 0 ? (
                <Stack align="center" py="xl" gap="sm">
                    <IconSearchOff size={48} stroke={1.5} color="gray" />
                    <Text fw={500} size="lg">
                        No articles found
                    </Text>
                    <Text size="sm" c="dimmed" ta="center" mx="auto" maw={300}>
                        We couldn&apos;t find any articles matching your
                        filters.
                    </Text>
                </Stack>
            ) : (
                <SimpleGrid cols={{ base: 1, sm: 2, md: 3 }} spacing="lg">
                    {data.items.map((article) => (
                        <ArticlesCard key={article.id} article={article} />
                    ))}
                </SimpleGrid>
            )}

            <Text size="sm" c="dimmed" mt="md">
                Showing {data.items.length} of {data.total} articles
            </Text>

            <PaginationControls
                total={data.total}
                currentPage={params.page}
                pageSize={10}
            />
        </Container>
    );
}
```

---

# 📘 Summary

| Step                   | Description                                      |
| ---------------------- | ------------------------------------------------ |
| Install the package    | `npm install @k34a/blog`                         |
| Create ArticleService  | Backend methods for fetching & listing articles  |
| Build an Article Card  | Use `resolveImageForArticle` for Supabase images |
| Add Search/Filter/Sort | Use `<FilterSearchSortArticles />`               |
| Add Pagination         | Connect query params to your router              |
| Render Articles        | With `<Article />` viewer                        |
| Render Listings        | Using built-in schema & utilities                |

---

# 📝 License

MIT © 2025 — Built with ❤️ by [K34A](https://k34a.vercel.app)