@k34a/blog
v0.0.20
Published
Create and share articles with your audience.
Readme
@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
npm install @k34a/blogRequired peer dependencies
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.
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.
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:
'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
'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
'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
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
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'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
