Skip to main content

Overview

EpiNeko uses Supabase Auth for secure, scalable authentication. The system supports email/password authentication with a unique username-based login feature.
All authentication operations are handled through Next.js Server Actions, providing a seamless developer experience without the need for separate API routes.

Authentication Architecture

EpiNeko implements a multi-layered authentication system:

Supabase Client Setup

EpiNeko uses two different Supabase clients depending on the execution context:

Browser Client

Used in client components for client-side operations:
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: NEXT_PUBLIC_SUPABASE_URL or NEXT_PUBLIC_SUPABASE_ANON_KEY');
  }

  return createBrowserClient(
    supabaseUrl,
    supabaseKey
  )
}

Server Client

Used in server components and server actions for server-side operations:
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'

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

  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 createServerClient(
    supabaseUrl,
    supabaseKey,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll()
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            )
          } catch {
            // The `setAll` method was called from a Server Component.
            // This can be ignored if you have middleware refreshing
            // user sessions.
          }
        },
      },
    }
  )
}
Always use the appropriate client for your context. Using the browser client in server components or vice versa will cause errors.

Authentication Flows

User Signup

The signup flow creates a new user account and automatically generates a profile:
'use server'

import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { createClient } from '@/utils/supabase/server'

export async function signup(formData: FormData) {
  const supabase = await createClient()

  const email = formData.get('email') as string
  const password = formData.get('password') as string
  const username = formData.get('username') as string
  
  // Use the username from form or extract default from email
  const finalUsername = username || email.split('@')[0]

  const { error } = await supabase.auth.signUp({
    email,
    password,
    options: {
      data: {
        full_name: finalUsername,
        username: finalUsername,
      }
    }
  })

  if (error) {
    console.error('Signup error:', error.message)
    redirect('/error')
  }

  revalidatePath('/', 'layout')
  redirect('/')
}
User metadata (username and full_name) is passed during signup and automatically used by the database trigger to create the profile.

User Login

EpiNeko supports login with both email and username:
export async function login(formData: FormData) {
  const supabase = await createClient()

  const identifier = formData.get('identifier') as string
  const password = formData.get('password') as string
  
  let email = identifier

  // If the identifier is not an email, look up the associated email
  if (!identifier.includes('@')) {
    const { data: profile } = await supabase
      .from('profiles')
      .select('email')
      .eq('username', identifier)
      .maybeSingle()
    
    if (profile?.email) {
      email = profile.email
    }
  }

  const { error } = await supabase.auth.signInWithPassword({
    email,
    password,
  })

  if (error) {
    console.error('Login error:', error.message)
    redirect('/error')
  }

  revalidatePath('/', 'layout')
  redirect('/')
}
1

User enters identifier

The user provides either an email or username along with their password
2

Identifier validation

The system checks if the identifier contains ’@’ to determine if it’s an email
3

Username lookup

If it’s a username, the system queries the profiles table to find the associated email
4

Authentication

Supabase Auth validates the email and password combination
5

Session creation

On success, a session is created and cookies are set

User Logout

Simple logout functionality that clears the session:
export async function signOut() {
  const supabase = await createClient()
  await supabase.auth.signOut()
  revalidatePath('/', 'layout')
  redirect('/')
}

Session Management

Middleware

EpiNeko uses Next.js middleware to automatically refresh user sessions on every request:
import { type NextRequest } from 'next/server'
import { updateSession } from '@/utils/supabase/middleware'

export async function middleware(request: NextRequest) {
  return await updateSession(request)
}

export const config = {
  matcher: [
    /*
     * Match all request paths except for the ones starting with:
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico (favicon file)
     * Feel free to modify this pattern to include more paths.
     */
    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ],
}
The middleware automatically refreshes the authentication token on every request, ensuring users stay logged in without manual intervention.

Profile Management

Automatic Profile Creation

Profiles are automatically created when a user signs up through a database trigger:
supabase/migrations/20260218_initial_schema.sql
-- Profiles Table
create table public.profiles (
  id uuid references auth.users on delete cascade not null primary key,
  updated_at timestamp with time zone,
  username text unique,
  full_name text,
  avatar_url text,
  website text,
  constraint username_length check (char_length(username) >= 3)
);

-- Trigger function
create or replace function public.handle_new_user()
returns trigger as $$
begin
  insert into public.profiles (id, full_name, username, avatar_url)
  values (
    new.id, 
    new.raw_user_meta_data->>'full_name', 
    new.raw_user_meta_data->>'username',
    new.raw_user_meta_data->>'avatar_url'
  );
  return new;
end;
$$ language plpgsql security definer;

-- Trigger
create trigger on_auth_user_created
  after insert on auth.users
  for each row execute procedure public.handle_new_user();

Row Level Security (RLS)

Profiles are protected with Row Level Security policies:
-- Enable RLS
alter table public.profiles enable row level security;

-- Public profiles are viewable by everyone
create policy "Public profiles are viewable by everyone." on public.profiles
  for select using (true);

-- Users can insert their own profile
create policy "Users can insert their own profile." on public.profiles
  for insert with check (auth.uid() = id);

-- Users can update own profile
create policy "Users can update own profile." on public.profiles
  for update using (auth.uid() = id);
RLS policies ensure that users can only modify their own profiles while still allowing public viewing of all profiles.

Getting the Current User

Fetch the authenticated user in server components:
import { createClient } from '@/utils/supabase/server'

export default async function ProtectedPage() {
  const supabase = await createClient()
  
  const { data: { user } } = await supabase.auth.getUser()
  
  if (!user) {
    redirect('/login')
  }
  
  return <div>Welcome, {user.email}</div>
}

Protecting Routes

Create protected routes by checking authentication status:
app/protected/page.tsx
import { createClient } from '@/utils/supabase/server'
import { redirect } from 'next/navigation'

export default async function ProtectedPage() {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
  
  if (!user) {
    redirect('/login')
  }
  
  // Protected content
  return <div>This is a protected page</div>
}
Always validate authentication on the server side, never rely solely on client-side checks for security.

Customization

Adding Social Authentication

To add OAuth providers (Google, GitHub, etc.):
  1. Enable the provider in Supabase Dashboard (Authentication > Providers)
  2. Add the OAuth redirect URL to your provider’s app settings
  3. Update your login page with social buttons:
export async function signInWithGoogle() {
  const supabase = await createClient()
  
  const { error } = await supabase.auth.signInWithOAuth({
    provider: 'google',
    options: {
      redirectTo: `${location.origin}/auth/callback`,
    },
  })
}

Email Verification

Enable email verification in Supabase Dashboard (Authentication > Email Templates):
const { error } = await supabase.auth.signUp({
  email,
  password,
  options: {
    emailRedirectTo: `${location.origin}/auth/callback`,
  }
})

Password Recovery

Implement password reset:
export async function resetPassword(email: string) {
  const supabase = createClient()
  
  const { error } = await supabase.auth.resetPasswordForEmail(email, {
    redirectTo: `${location.origin}/auth/reset-password`,
  })
}

Troubleshooting

Ensure middleware is properly configured and running on all routes. Check that cookies are being set correctly.
Verify that the username is stored in the profiles table and is properly indexed. Check that the email column is added to profiles.
Check that the database trigger is installed correctly. Verify that user metadata is being passed during signup.