Back to Blog
Web Development10 min readJune 25, 2026

Building Elevate Fitness: Lessons from a Real Next.js + Supabase App

What I learned about the Next.js App Router, Supabase auth, and building a real AI-powered app while shipping Elevate Fitness as a student developer.

Next.jsSupabaseApp RouterTypeScriptTailwind CSSAgentic AIMCP

Starting Small, Building It Right

I'm not going to pretend I've run a large-scale SaaS platform with thousands of paying users. What I have done is design, build, and ship Elevate Fitness — an AI-powered fitness app with interactive SVG exercise demos, personalized AI-driven workout and diet plans, a BMI calculator, and a calorie counter — as a real, live, working product at elevate-your-fitness.vercel.app. It's a smaller-scale project than an enterprise SaaS, but every decision I made — routing structure, auth, state management, AI integration — is the same class of decision you'd make at a bigger scale. Here's what I actually learned building it.

App Router: Thinking in Server and Client Boundaries

The single biggest mental shift moving to the Next.js App Router was learning to default to Server Components and only reach for "use client" when I actually needed interactivity or browser APIs.

For Elevate Fitness, that split looked roughly like this:

  • Server Components: dashboard data fetching, workout plan retrieval, layout shells, anything reading from Supabase that didn't need client-side state
  • Client Components: the BMI calculator form, the interactive SVG exercise viewer (needs click/hover state), the calorie counter's live input handling
// app/exercises/[slug]/page.tsx — Server Component
import { getExerciseBySlug } from "@/lib/exercises";
import ExerciseViewer from "./exercise-viewer"; // client component

export default async function ExercisePage({
  params,
}: {
  params: { slug: string };
}) {
  const exercise = await getExerciseBySlug(params.slug);
  return <ExerciseViewer exercise={exercise} />;
}
// app/exercises/[slug]/exercise-viewer.tsx
"use client";
import { useState } from "react";

export default function ExerciseViewer({ exercise }: { exercise: Exercise }) {
  const [activeStep, setActiveStep] = useState(0);
  return (
    <svg viewBox="0 0 400 400" onClick={() => setActiveStep((s) => (s + 1) % exercise.steps.length)}>
      {exercise.steps[activeStep].path}
    </svg>
  );
}

Keeping data-fetching out of client components meant less client-side JavaScript shipped to the browser, and fewer waterfalls where a component mounts, then fetches, then re-renders.

Supabase Auth: Getting Session Handling Right the First Time

The mistake I see (and made early on) is treating Supabase auth as "just call signInWithPassword and store the token." The App Router's server/client split means you need two Supabase clients — one for server contexts (Server Components, Route Handlers) that reads cookies, and one for client components:

// lib/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";

export function getServerSupabase() {
  const cookieStore = cookies();
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        get: (name) => cookieStore.get(name)?.value,
      },
    }
  );
}
// lib/supabase/client.ts
import { createBrowserClient } from "@supabase/ssr";

export function getBrowserSupabase() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  );
}

Getting this split right early avoided an entire category of bugs where auth state was correct in the browser but stale or missing on the server-rendered page — the classic "why is my user logged out on refresh" issue.

Row-Level Security Instead of Hand-Written Authorization

Rather than writing if (userId !== record.userId) throw new Error(...) in every single query, I pushed authorization down into Postgres itself using Supabase's Row-Level Security:

create policy "Users can only view their own workout plans"
on workout_plans
for select
using (auth.uid() = user_id);

create policy "Users can only insert their own workout plans"
on workout_plans
for insert
with check (auth.uid() = user_id);

Once these policies existed, every query — from a Server Component, a Route Handler, or a client-side fetch — was automatically scoped correctly. This is one of the biggest practical wins of Supabase over hand-rolling a backend: authorization becomes a database-level guarantee instead of something you have to remember to check in every handler.

Wiring in Agentic AI for Personalized Plans

The AI-driven workout and diet plan generation uses an agentic approach through MCP (Model Context Protocol) servers rather than a single hardcoded prompt. In practice, that meant structuring the plan generation as a small pipeline: gather user inputs (goals, current fitness level, dietary restrictions) → call the planning agent → validate the structured output → persist it to Supabase.

// app/api/generate-plan/route.ts
import { NextResponse } from "next/server";
import { generateWorkoutPlan } from "@/lib/agent/plan-generator";
import { getServerSupabase } from "@/lib/supabase/server";

export async function POST(req: Request) {
  const supabase = getServerSupabase();
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

  const input = await req.json();
  const plan = await generateWorkoutPlan(input);

  await supabase.from("workout_plans").insert({
    user_id: user.id,
    plan_data: plan,
  });

  return NextResponse.json({ plan });
}

Validating the AI's structured output before writing to the database mattered more than I expected — LLM output is not guaranteed to match your schema, and a malformed plan silently saved to the database is a much worse bug than a request that fails loudly.

What "Production" Actually Meant at This Scale

I didn't need a multi-region deployment or a dedicated DevOps setup. What I did need, and what I think matters regardless of scale:

  • Environment variables properly scoped between NEXT_PUBLIC_* (safe for the browser) and server-only secrets
  • Loading and error states for every async boundary — Next.js's loading.tsx and error.tsx file conventions made this close to free
  • Basic rate-limiting awareness on the plan-generation endpoint, since AI calls are the most expensive operation in the app
  • Deploying on Vercel with preview deployments for every change, so I could sanity-check a feature before it reached the main branch

None of this required enterprise infrastructure. It required treating a student project with the same care I'd want in a professional one — because the habits you build on a small app are the habits you'll bring to a bigger one.

See It Running

Elevate Fitness is live and usable, not a demo repo: elevate-your-fitness.vercel.app.

If you're building something similar with Next.js and Supabase and want to compare notes on App Router patterns, auth, or wiring AI into a real app, email me at rishabnishad22@gmail.com, reach out on WhatsApp, or check rishab-nishad.vercel.app/#contact.

Written by

Rishab Nishad

AI & Automation Engineer, currently RPA Developer Intern at Avent IQ. Building RPA bots, AI/LLM automation workflows, and full-stack web applications.

Related Articles