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
SKAKIO_STORE_ID=store_abc123

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!);
export const STORE_ID = process.env.SKAKIO_STORE_ID!;

Page Examples

Store Page (App Router)

Create app/page.tsx:

import { skakio, STORE_ID } from '@/lib/skakio';
import Link from 'next/link';
import Image from 'next/image';

interface Store {
  id: string;
  name: string;
  description: string;
  logo?: { url: string };
}

interface Listing {
  id: string;
  name: string;
  publicationCount: number;
}

export default async function HomePage() {
  const [store, listings] = await Promise.all([
    skakio.fetch<Store>(`/api/store/${STORE_ID}`),
    skakio.fetch<Listing[]>(`/api/store/${STORE_ID}/listings`),
  ]);

  return (
    <main className="container mx-auto px-4 py-8">
      <header className="text-center mb-12">
        {store.logo && (
          <Image
            src={store.logo.url}
            alt={store.name}
            width={120}
            height={120}
            className="mx-auto rounded-full"
          />
        )}
        <h1 className="text-4xl font-bold mt-4">{store.name}</h1>
        <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.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.publicationCount} 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; currency: string };
  media?: { url: string }[];
}

interface Listing {
  id: string;
  name: string;
  description: string;
  publications?: 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>
      <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?.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?.[0] && (
                <Image
                  src={product.media[0].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.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; currency: string };
  quantity: { available: number };
  media?: { url: string; alt?: string }[];
}

interface Review {
  id: string;
  rating: number;
  title: string;
  content: string;
  author?: { name: string };
}

export default async function ProductPage({
  params,
}: {
  params: { id: string };
}) {
  const [product, reviewsData] = await Promise.all([
    skakio.fetch<Publication>(`/api/publication/${params.id}`),
    skakio.fetch<Review[]>(`/api/publication/${params.id}/reviews?limit=5`),
  ]);

  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?.map((m, i) => (
            <div key={i} className="aspect-square relative rounded-lg overflow-hidden">
              <Image
                src={m.url}
                alt={m.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.toFixed(2)}
          </p>
          <p className="mt-4 text-gray-700">{product.description}</p>

          <div className="mt-6">
            {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>

      {/* Reviews */}
      <section className="mt-12">
        <h2 className="text-2xl font-semibold mb-6">Reviews</h2>
        {reviewsData.length > 0 ? (
          <div className="space-y-4">
            {reviewsData.map((review) => (
              <div key={review.id} className="border-b pb-4">
                <div className="text-yellow-500">
                  {'★'.repeat(review.rating)}
                  {'☆'.repeat(5 - review.rating)}
                </div>
                <h4 className="font-medium mt-1">{review.title}</h4>
                <p className="text-gray-600">{review.content}</p>
                <span className="text-sm text-gray-400">
                  {review.author?.name}
                </span>
              </div>
            ))}
          </div>
        ) : (
          <p className="text-gray-500">No reviews yet.</p>
        )}
      </section>
    </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?.publications && (
        <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
          {results.publications.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}
              </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;

  return skakio.fetch(
    `/api/marketplace/search?q=${encodeURIComponent(query)}&limit=20`,
    { revalidate: 0 }
  );
}

Static Generation

Generate static pages at build time:

// app/category/[id]/page.tsx
import { skakio, STORE_ID } from '@/lib/skakio';

export async function generateStaticParams() {
  const listings = await skakio.fetch<{ id: string }[]>(
    `/api/store/${STORE_ID}/listings`
  );

  return listings.map((listing) => ({
    id: listing.id,
  }));
}

Caching Strategies

// Static data (revalidate every hour)
const store = await skakio.fetch('/api/store/123', { revalidate: 3600 });

// Dynamic data (no cache)
const search = await skakio.fetch('/api/marketplace/search?q=bike', {
  revalidate: 0,
});

// On-demand revalidation
import { revalidatePath } from 'next/cache';
revalidatePath('/category/123');