Flashio - AI Powered Flashcards

https://flashio-one.vercel.app/

Flashio is a simple project I built to learn Supabase and Framer Motion.
It's not a production-level app — just a playground where I experimented and tested ideas.

It's a gamified XP-based game featuring Clever Shards as the in-game currency.
Clever Shards are required to purchase packs, while XP is required to unlock them.
Packs are organized into tiers — from Basic to Mythic — each increasing in difficulty.

Questions are generated via a simple OpenAI API call.
I've preloaded the project with some credits, so it should work for a while.

Features

  • Pack system: Packs go up in difficulty and price
  • XP / Rewards: Earn clever-shards and XP for opening packs and answering correctly
  • Unlocking logic: Packs require specific XP tiers (shown as a big badge at the top)
  • UI: Retro-inspired interface — sharp corners, vibrant colors, not the typical flat design
  • Gamification loop: Feels like a progression system instead of a static quiz
  • Responsive: Works decently on mobile (tested via devtools and phone)

Architecture & Stack

SupabaseSupabase NextJSNextJS TypescriptTypescript TailwindCSSTailwindCSS ShadcnUIShadcnUI ZustandZustand GithubGithub

Challenges

I faced quite a variety of challenges when building out Flashio. Particularly, I struggled with the backend implementations of client-side auth, and Realtime channel setup. I also struggled a bit with the Zustand stores but thats simply because it was new to me, the library is amazing for global state. It's a lot lighter than Redux and much more enjoyable to use.

Challenge 1 - The Auth Issue

I have always sucked a little at implementing auth, thats why I left this step until way at the end, yet it also proved to be the hardest to solve. The main issue here was that when implementing motion animation to the site at the same time every page ended up becoming client (except for the non-game pages like profile).

I solved this by having my user store actually have a realtime state of my authenticated user. You can find it on github since the repo is public, look for the initAuthListener function. Then I would just do some checks on the front-end, but I noticed I would have to run a useEffect of every page, so I just made a wrapper for it like this

"use client";

import { ReactNode, useEffect } from "react";
import { usePathname, useRouter } from "next/navigation";

import Loader from "@/components/ui/loader";
import { useUserStore } from "@/app/stores/user-store";

interface ProtectedProps {
  children: ReactNode;
}

export default function ProtectedRoute({ children }: ProtectedProps) {
  const currentPath = usePathname();
  const router = useRouter();
  const { authUser, loading, initAuthListener } = useUserStore();

  useEffect(() => {
    const unsubscribe = initAuthListener();
    return () => unsubscribe?.();
  }, []);

  useEffect(() => {
    if (!loading && !authUser && currentPath !== "/auth/get-authed") {
      router.push("/auth/get-authed");
    }
  }, [loading, authUser, router]);

  if ((loading || !authUser) && currentPath !== "/auth/get-authed") {
    return (
      <div className="flex items-center justify-center h-screen">
        <Loader />
      </div>
    );
  }

  return <>{children}</>;
}

and then wrap my layout

...
export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en" style={{ background: "var(--ray-color)" }}>
      <body
        className={`${londrinaShadow.variable} ${londrinaSolid.variable} antialiased `}
        style={{ background: "var(--ray-color)" }}
      >
        <ProtectedRoute>
          {children}
          <RenderFlash />
        </ProtectedRoute>
      </body>
    </html>
  );
}

I figured this was the most straightforward way, since it checks my whole app from the root layout and forces a redirect. This was purely client side which was fine for my UI, but not for my API. For My api protection I had to switch to server side auth. To do that I created a server.ts file and just copy pasted supabase's template for the @supabase/ssr package. Then made a nice reusable requireAuth() hook that throws and error if no user session is found from the built in getUser() method

import { supabaseClient } from "@/supabase/client";
import { User } from "@supabase/supabase-js";

export async function requireAuth(): Promise<User> {
  const {
    data: { user },
    error,
  } = await supabaseClient.auth.getUser();

  if (error || !user) {
    throw new Error("User not authenticated");
  }

  return user;
}


then just await at the top of my API

export async function DELETE() {
  try {
    const user = await requireAuth(); //protect route NOTE: will throw error if no auth
    await deleteCurrentUser(user.id);

    return NextResponse.json({ message: "User account and all data deleted" });
  } catch (err: unknown) {
    if (err instanceof Error) {
      return NextResponse.json({ error: err.message }, { status: 401 });
    }
    return NextResponse.json({ error: "Unknown error" }, { status: 500 });
  }
}

Since in my generate-questions route I don't use user for anything, I just await and check for an error

import { Flashcard } from "@/app/stores/flashcard-store";
import { requireAuth } from "@/supabase/auth/require-auth";
import OpenAI from "openai";

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

export async function POST(request: Request) {
  try {
    await requireAuth(); // protect route NOTE: will throw error if no auth

    const body = await request.json();
    const { level } = body;
    ...

Challenge 2 - The Realtime Issue

I faced quite a few annoyances when implementing the realtime channel, but I wanted to have realtime updates to both clever shards and user XP. As it turns out, working with realtime channels in dev is REALLY annoying because of the strict mode. It causes a double render of components, which also meant it would try to start a realtime channel twice really fast for being called in a useEfect.

export default function Home() {
  const { userXp, loading, authUser, initAuthListener } = useUserStore();
  const view = useViewStore((state) => state.view);
  const router = useRouter();

  useEffect(() => {
    const unsubscribe = initAuthListener();
    return () => unsubscribe?.();
  }, []);

To get around it I just changed my next.config to not use strict mode while I worked on this. I created a channel type to stay strict and then made the clearChannel() and subscribeToUser() functions. They are pretty self explanatory. The clearChannel function just unsubscribes and resets userChannelRef

const clearChannel = () => {
    if (userChannelRef.unsubscribed) return; // guard
    userChannelRef.unsubscribed = true;

    try {
      if (userChannelRef.channel) {
        try {
          userChannelRef.channel.unsubscribe();
        } catch (e) {
          console.warn("Error while unsubscribing channel:", e);
        }
      }
    } finally {
      if (userChannelRef.retryTimer) {
        clearTimeout(userChannelRef.retryTimer);
      }
      userChannelRef.channel = null;
      userChannelRef.retryCount = 0;
      userChannelRef.retryTimer = null;
    }
  };

I also learnt a nifty exponential backoff strategy to avoid flooding supabase with requests, its not complex but I had never thought about it. It takes the number of attempts so far and raises is to a max of 16 seconds.

    const backoff = (attempt: number) => {
      const base = 1000; // 1s
      const max = 16000; // cap to 16s
      return Math.min(base * 2 ** attempt, max);
    };

The subscribe to user really long since I took this part quite seriously, so I wont post it here. You can find it here . subscribeToUser sets up a realtime listener for a specific user row in the database. It first checks if there's already an active channel, then ensures a valid session token exists, and finally subscribes to updates on that user's row. Any changes to fields like xp or clever shards are automatically reflected in the Zustand store, and provides proper cleanup to avoid memory leaks.

The End

If you test it out and have any suggestions for improvements feel free to reach out through here or on github. Thank you for reading all the way through.

Reach Out

Wether you just want to say hi, you want to collaborate on a project, or if you're hiring I'd love to hear from you

Copyright © 2025 Jose Buxo. All rights