Next.js Integration
Build a storefront with Next.js App Router using the Skakio Catalog API.
Setup
1. Configure Environment Variables
Create .env.local:
SKAKIO_API_KEY=pk_live_your_key_here
2. Create API Client
Create lib/skakio.ts:
const API_BASE = 'https://api.skakio.com';
interface TokenData {
token: string;
expires_in: number;
}
class SkakioClient {
private apiKey: string;
private token: string | null = null;
private tokenExpiresAt: number = 0;
constructor(apiKey: string) {
this.apiKey = apiKey;
}
private async getToken(): Promise<string> {
const now = Date.now();
const buffer = 60 * 1000;
if (!this.token || now > this.tokenExpiresAt - buffer) {
const res = await fetch(`${API_BASE}/auth/token`, {
method: 'POST',
headers: { 'X-API-Key': this.apiKey },
cache: 'no-store',
});
if (!res.ok) throw new Error('Failed to get token');
const { data }: { data: TokenData } = await res.json();
this.token = data.token;
this.tokenExpiresAt = now + data.expires_in * 1000;
}
return this.token!;
}
async fetch<T>(path: string, options?: { revalidate?: number }): Promise<T> {
const token = await this.getToken();
const res = await fetch(`${API_BASE}${path}`, {
headers: { Authorization: `Bearer ${token}` },
next: { revalidate: options?.revalidate ?? 60 },
});
if (!res.ok) {
throw new Error(`API error: ${res.status}`);
}
const { data } = await res.json();
return data;
}
}
export const skakio = new SkakioClient(process.env.SKAKIO_API_KEY!);
Page Examples
Store Page (App Router)
Create app/page.tsx:
import { skakio } from '@/lib/skakio';
import Link from 'next/link';
import Image from 'next/image';
interface Store {
id: string;
name: string;
description?: string;
media?: {
data: { media_data: { url: string } }[];
};
}
interface Listing {
id: string;
name: string;
stats?: { publication_count: number };
}
export default async function HomePage() {
const [store, listings] = await Promise.all([
skakio.fetch<Store>('/api/store'),
skakio.fetch<{ data: Listing[] }>('/api/store-listings'),
]);
return (
<main className="container mx-auto px-4 py-8">
<header className="text-center mb-12">
<h1 className="text-4xl font-bold mt-4">{store.name}</h1>
{store.description && (
<p className="text-gray-600 mt-2">{store.description}</p>
)}
</header>
<section>
<h2 className="text-2xl font-semibold mb-6">Categories</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{listings.data.map((listing) => (
<Link
key={listing.id}
href={`/category/${listing.id}`}
className="p-6 border rounded-lg hover:shadow-lg transition"
>
<h3 className="font-semibold">{listing.name}</h3>
<p className="text-sm text-gray-500">
{listing.stats?.publication_count || 0} products
</p>
</Link>
))}
</div>
</section>
</main>
);
}
Category Page
Create app/category/[id]/page.tsx:
import { skakio } from '@/lib/skakio';
import Link from 'next/link';
import Image from 'next/image';
interface Publication {
id: string;
title: string;
price_amount: number;
price_currency: string;
media?: {
data: { media_data: { url: string } }[];
};
}
interface Listing {
id: string;
name: string;
description?: string;
publications?: {
data: Publication[];
};
}
export default async function CategoryPage({
params,
}: {
params: { id: string };
}) {
const listing = await skakio.fetch<Listing>(
`/api/listing/${params.id}?expand=publication`
);
return (
<main className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold">{listing.name}</h1>
{listing.description && (
<p className="text-gray-600 mt-2">{listing.description}</p>
)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 mt-8">
{listing.publications?.data?.map((product) => (
<Link
key={product.id}
href={`/product/${product.id}`}
className="group"
>
<div className="aspect-square relative overflow-hidden rounded-lg bg-gray-100">
{product.media?.data?.[0] && (
<Image
src={product.media.data[0].media_data.url}
alt={product.title}
fill
className="object-cover group-hover:scale-105 transition"
/>
)}
</div>
<h3 className="mt-2 font-medium truncate">{product.title}</h3>
<p className="text-green-600 font-semibold">
{product.price_currency} {(product.price_amount / 100).toFixed(2)}
</p>
</Link>
))}
</div>
</main>
);
}
Product Page
Create app/product/[id]/page.tsx:
import { skakio } from '@/lib/skakio';
import Image from 'next/image';
interface Publication {
id: string;
title: string;
description: string;
price_amount: number;
price_currency: string;
quantity_available?: number;
media?: {
data: { media_data: { url: string } }[];
};
}
export default async function ProductPage({
params,
}: {
params: { id: string };
}) {
const product = await skakio.fetch<Publication>(
`/api/publication/${params.id}`
);
return (
<main className="container mx-auto px-4 py-8">
<div className="grid md:grid-cols-2 gap-8">
{/* Images */}
<div className="space-y-4">
{product.media?.data?.map((m, i) => (
<div key={i} className="aspect-square relative rounded-lg overflow-hidden">
<Image
src={m.media_data.url}
alt={product.title}
fill
className="object-cover"
priority={i === 0}
/>
</div>
))}
</div>
{/* Info */}
<div>
<h1 className="text-3xl font-bold">{product.title}</h1>
<p className="text-2xl text-green-600 font-bold mt-4">
{product.price_currency} {(product.price_amount / 100).toFixed(2)}
</p>
<p className="mt-4 text-gray-700">{product.description}</p>
<div className="mt-6">
{product.quantity_available != null && product.quantity_available > 0 ? (
<span className="text-green-600">
In Stock ({product.quantity_available} available)
</span>
) : (
<span className="text-red-600">Out of Stock</span>
)}
</div>
</div>
</div>
</main>
);
}
Search with Client Component
Create app/search/page.tsx:
'use client';
import { useState, useTransition } from 'react';
import Link from 'next/link';
import { searchProducts } from './actions';
export default function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<any>(null);
const [isPending, startTransition] = useTransition();
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
startTransition(async () => {
const data = await searchProducts(query);
setResults(data);
});
};
return (
<main className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">Search</h1>
<form onSubmit={handleSearch} className="flex gap-2 mb-8">
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search products..."
className="flex-1 px-4 py-2 border rounded-lg"
/>
<button
type="submit"
disabled={isPending}
className="px-6 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50"
>
{isPending ? 'Searching...' : 'Search'}
</button>
</form>
{results?.data && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{results.data.map((product: any) => (
<Link key={product.id} href={`/product/${product.id}`}>
<h3 className="font-medium">{product.title}</h3>
<p className="text-green-600">
{product.price_currency} {(product.price_amount / 100).toFixed(2)}
</p>
</Link>
))}
</div>
)}
</main>
);
}
Create app/search/actions.ts:
'use server';
import { skakio } from '@/lib/skakio';
export async function searchProducts(query: string) {
if (!query) return null;
const storeId = process.env.SKAKIO_STORE_ID!;
return skakio.fetch(
`/api/store/search?store_id=${storeId}&q=${encodeURIComponent(query)}&limit=20`,
{ revalidate: 0 }
);
}
Static Generation
Generate static pages at build time:
// app/category/[id]/page.tsx
import { skakio } from '@/lib/skakio';
export async function generateStaticParams() {
const listings = await skakio.fetch<{ data: { id: string }[] }>(
'/api/store-listings'
);
return listings.data.map((listing) => ({
id: listing.id,
}));
}
Caching Strategies
// Static data (revalidate every hour)
const store = await skakio.fetch('/api/store', { revalidate: 3600 });
// Dynamic data (no cache)
const search = await skakio.fetch('/api/store/search?store_id=store_abc123&q=bike', {
revalidate: 0,
});
// On-demand revalidation
import { revalidatePath } from 'next/cache';
revalidatePath('/category/123');