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:
Rate Limit Detection
Catches 429 status and returns a user-friendly message
HTTP Error Handling
Throws descriptive errors for failed requests
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
Fetching with Loading State
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