Skip to main content

Overview

EpiNeko uses a service layer pattern to abstract external API calls and data operations. This provides a clean separation between UI components and data fetching logic.

Service Architecture

jikan.ts

Fetches anime data from MyAnimeList via Jikan API

library.ts

Manages user library with Supabase database

Jikan Service

The Jikan service provides access to the Jikan API v4, an unofficial MyAnimeList API.

Base Configuration

const JIKAN_API_BASE = 'https://api.jikan.moe/v4';

async function jikanFetch<T>(endpoint: string, revalidate = 3600): Promise<T> {
  const response = await fetch(`${JIKAN_API_BASE}${endpoint}`, {
    next: { revalidate: revalidate }
  });

  if (!response.ok) {
    if (response.status === 429) {
      throw new Error('Jikan API rate limit exceeded. Please try again later.');
    }
    throw new Error(`Jikan API error: ${response.statusText}`);
  }

  return response.json();
}
The service uses Next.js’s next.revalidate for automatic cache management. Default cache time is 3600 seconds (1 hour).

Type Definitions

export interface JikanAnime {
  mal_id: number;
  url: string;
  images: {
    jpg: JikanImage;
    webp: JikanImage;
  };
  title: string;
  title_english: string | null;
  title_japanese: string | null;
  type: string;
  episodes: number | null;
  status: string;
  score: number | null;
  synopsis: string | null;
  background: string | null;
  season: string | null;
  year: number | null;
}
export interface JikanImage {
  image_url: string;
  small_image_url: string;
  large_image_url: string;
}
export interface JikanEpisode {
  mal_id: number;
  url: string;
  title: string;
  title_japanese: string | null;
  title_romanji: string | null;
  duration: number | null;
  aired: string | null;
  filler: boolean;
  recap: boolean;
  forum_url: string | null;
}
export interface JikanResponse<T> {
  data: T;
  pagination?: {
    last_visible_page: number;
    has_next_page: boolean;
    current_page: number;
    items: {
      count: number;
      total: number;
      per_page: number;
    };
  };
}

API Functions

getTopAnime

Fetch the top-rated anime from MyAnimeList.
export const getTopAnime = async (
  page = 1
): Promise<JikanResponse<JikanAnime[]>> => {
  return jikanFetch<JikanResponse<JikanAnime[]>>(
    `/top/anime?page=${page}`, 
    3600 // 1 hour cache
  );
};
Usage:
const res = await getTopAnime(1);
const animeList = res.data;

searchAnime

Search for anime by query string.
export const searchAnime = async (
  query: string, 
  page = 1
): Promise<JikanResponse<JikanAnime[]>> => {
  return jikanFetch<JikanResponse<JikanAnime[]>>(
    `/anime?q=${encodeURIComponent(query)}&page=${page}`, 
    3600
  );
};
Usage:
const results = await searchAnime("Naruto", 1);
const foundAnime = results.data;
The query is automatically URL-encoded to handle special characters safely.

getAnimeById

Fetch detailed information for a specific anime.
export const getAnimeById = async (
  id: number
): Promise<JikanResponse<JikanAnime>> => {
  return jikanFetch<JikanResponse<JikanAnime>>(
    `/anime/${id}`, 
    86400 // 24 hours cache
  );
};
Usage:
const response = await getAnimeById(anime.mal_id);
const animeDetails = response.data;
Individual anime details are cached for 24 hours since they change infrequently.

getAnimeCharacters

Fetch character list for an anime.
export const getAnimeCharacters = async (
  id: number
): Promise<JikanResponse<any[]>> => {
  return jikanFetch<JikanResponse<any[]>>(
    `/anime/${id}/characters`, 
    86400
  );
};

getAnimeEpisodes

Fetch episode list for an anime with pagination.
export const getAnimeEpisodes = async (
  id: number, 
  page = 1
): Promise<JikanResponse<JikanEpisode[]>> => {
  return jikanFetch<JikanResponse<JikanEpisode[]>>(
    `/anime/${id}/episodes?page=${page}`, 
    86400
  );
};
Usage:
const episodes = await getAnimeEpisodes(animeId);
const episodeList = episodes.data;

Error Handling

The Jikan service includes built-in error handling:
1

Rate Limit Detection

Catches 429 status and returns a user-friendly message
2

HTTP Error Handling

Throws descriptive errors for failed requests
3

Automatic Retries

Next.js cache system handles stale data revalidation

Library Service

The Library service manages user anime collections using Supabase.

Type Definitions

export type LibraryStatus = 'watching' | 'completed' | 'dropped' | 'plan_to_watch';

export interface LibraryItem {
  id?: string;
  user_id?: string;
  anime_id_jikan: number;
  title: string;
  image_url?: string;
  status: LibraryStatus;
  score?: number;
  episodes_watched?: number;
}

Database Operations

addToLibrary

Add a new anime to the user’s library.
export const addToLibrary = async (item: LibraryItem) => {
  const supabase = createClient();
  const { data: { user } } = await supabase.auth.getUser();

  if (!user) throw new Error('User must be logged in to add to library');

  const { data, error } = await supabase
    .from('user_library')
    .insert([{ ...item, user_id: user.id }])
    .select()
    .single();

  if (error) throw error;
  return data;
};
Usage:
const item: LibraryItem = {
  anime_id_jikan: anime.mal_id,
  title: anime.title,
  image_url: anime.images.webp.large_image_url,
  status: 'plan_to_watch',
};

await addToLibrary(item);
This function requires an authenticated user. It throws an error if called without authentication.

removeFromLibrary

Remove an anime from the user’s library.
export const removeFromLibrary = async (animeIdJikan: number) => {
  const supabase = createClient();
  const { data: { user } } = await supabase.auth.getUser();

  if (!user) throw new Error('User must be logged in to remove from library');

  const { error } = await supabase
    .from('user_library')
    .delete()
    .match({ user_id: user.id, anime_id_jikan: animeIdJikan });

  if (error) throw error;
};
Usage:
await removeFromLibrary(anime.mal_id);

updateLibraryItem

Update an existing library entry.
export const updateLibraryItem = async (
  animeIdJikan: number, 
  updates: Partial<LibraryItem>
) => {
  const supabase = createClient();
  const { data: { user } } = await supabase.auth.getUser();

  if (!user) throw new Error('User must be logged in to update library');

  const { data, error } = await supabase
    .from('user_library')
    .update(updates)
    .match({ user_id: user.id, anime_id_jikan: animeIdJikan })
    .select()
    .single();

  if (error) throw error;
  return data;
};
Usage:
// Update watch status
await updateLibraryItem(animeId, { 
  status: 'watching',
  episodes_watched: 5
});

// Update score
await updateLibraryItem(animeId, { 
  score: 9
});

getLibrary

Retrieve all library items for the current user.
export const getLibrary = async () => {
  const supabase = createClient();
  const { data, error } = await supabase
    .from('user_library')
    .select('*')
    .order('updated_at', { ascending: false });

  if (error) throw error;
  return data;
};
Usage:
const myLibrary = await getLibrary();
// Returns array of LibraryItem objects
Results are automatically sorted by most recently updated first.

getLibraryItem

Check if a specific anime is in the user’s library.
export const getLibraryItem = async (animeIdJikan: number) => {
  const supabase = createClient();
  const { data: { user } } = await supabase.auth.getUser();

  if (!user) return null;

  const { data, error } = await supabase
    .from('user_library')
    .select('*')
    .match({ user_id: user.id, anime_id_jikan: animeIdJikan })
    .single();

  if (error && error.code !== 'PGRST116') throw error;
  return data;
};
Usage:
const libraryItem = await getLibraryItem(anime.mal_id);
const isInLibrary = !!libraryItem;
const currentStatus = libraryItem?.status;
This function returns null for unauthenticated users instead of throwing an error, making it safe to call in any context.

Supabase Utilities

Client-Side Client

For browser-based operations:
src/utils/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'

export function createClient() {
  const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
  const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;

  if (!supabaseUrl || !supabaseKey) {
    throw new Error('Missing Supabase environment variables');
  }

  return createBrowserClient(supabaseUrl, supabaseKey);
}

Server-Side Client

For server components and API routes:
src/utils/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'

export async function createClient() {
  const cookieStore = await cookies();

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll()
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            )
          } catch {
            // Ignore if called from Server Component
          }
        },
      },
    }
  );
}

Middleware Client

For session management in middleware:
src/utils/supabase/middleware.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'

export async function updateSession(request: NextRequest) {
  let supabaseResponse = NextResponse.next({ request });

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll()
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value }) => 
            request.cookies.set(name, value)
          );
          supabaseResponse = NextResponse.next({ request });
          cookiesToSet.forEach(({ name, value, options }) =>
            supabaseResponse.cookies.set(name, value, options)
          );
        },
      },
    }
  );

  await supabase.auth.getUser();
  return supabaseResponse;
}

Best Practices

Error Handling

Always wrap service calls in try-catch blocks

Loading States

Show feedback while waiting for API responses

Cache Strategy

Use appropriate revalidation times per endpoint

Authentication

Check user state before library operations

Common Patterns

const [data, setData] = useState<JikanAnime[]>([]);
const [isLoading, setIsLoading] = useState(true);

useEffect(() => {
  const fetchData = async () => {
    try {
      const res = await getTopAnime();
      setData(res.data);
    } catch (error) {
      console.error('Fetch error:', error);
    } finally {
      setIsLoading(false);
    }
  };
  
  fetchData();
}, []);
const handleToggle = async () => {
  // Update UI immediately
  setIsInLibrary(true);
  
  try {
    // Persist to database
    await addToLibrary(item);
  } catch (error) {
    // Revert on error
    setIsInLibrary(false);
    console.error(error);
  }
};
const [episodes, library] = await Promise.all([
  getAnimeEpisodes(animeId),
  getLibraryItem(animeId)
]);

Components

See how components use services

Environment Variables

Configure API credentials

Project Structure

Understand service organization