From Flask Monolith to Multiplatform App: Migration Journey Part 1

learningpythonreactdocs

From Flask Monolith to Multiplatform App: Migration Journey Part 1

A walkthrough of the first migration steps to a Multiplatform App

Table of Contents

  1. The Starting Point
  2. The Vision
  3. Phase 1: Building the REST API Layer
  4. Restructuring into a Monorepo
  5. Phase 2a: Supabase Auth Migration
  6. Phase 2b: Next.js Frontend (Milestone 0+1)
  7. Gotchas and Lessons Learned
  8. Where We Are Now

1. The Starting Point

The Calisthenics Progression app started as a classic Flask monolith: server-rendered HTML with Jinja2 templates, Bootstrap 5 for styling, jQuery for interactivity, and Flask-Login for session-based authentication. It had ~20 pages covering workout tracking, exercise management, workout templates, social features (follow/unfollow), messaging, and user profiles.

The tech stack was:

TechnologyRole
Flask 3.1Web framework
SQLAlchemy 2.0ORM with DeclarativeBase
Jinja2 + Bootstrap 5Server-rendered templates
Flask-LoginSession authentication
Flask-WTFForm handling with CSRF
jQuery + Underscore.jsClient-side interactivity
Supabase PostgreSQLProduction database (Pro plan)
SQLiteLocal development database
AlembicDatabase migrations

Everything worked, but it was locked into a browser-only experience. The goal: make it work on mobile too.


2. The Vision

The idea was straightforward but the architecture decision was important: one backend, multiple frontends.

Architecture

Two key decisions shaped everything:

Decision 1: Separate frontends, not React Native Web. React Native can technically run in a browser, but the developer experience and web-specific features (SEO, SSR, URL-based routing) are much better with a dedicated web framework. So: Next.js for web, Expo for mobile, both consuming the same Flask API.

Decision 2: Supabase Auth instead of rolling our own. The Flask app already used Supabase for its PostgreSQL database. Supabase Auth is included in the Pro plan and handles login, registration, password reset, email confirmation, and even OAuth β€” all things Flask was doing manually with werkzeug.security, itsdangerous tokens, and Flask-Mail. Delegating auth to Supabase means Flask becomes a pure data API.

πŸ“– Supabase Auth docs | Next.js App Router | Flask Blueprints


3. Phase 1: Building the REST API Layer

The first step was adding a JSON API alongside the existing Jinja2 frontend. Both had to coexist β€” no breaking changes to the web app.

3.1 The Blueprint Pattern

Flask Blueprints let you group related routes into modules. I created a new project/api/ package with its own blueprint:

# project/api/__init__.py
from flask import Blueprint

bp = Blueprint("api", __name__)

from project.api import (  # noqa: E402, F401
    auth_routes,
    category_routes,
    errors,
    exercise_routes,
    social_routes,
    workout_routes,
)

Registered at /api/v1/ in the app factory:

# project/__init__.py
from project.api import bp as api_bp
app.register_blueprint(api_bp, url_prefix="/api/v1")

# CORS for API routes only β€” web routes don't need it
CORS(app, resources={r"/api/*": {"origins": "*"}})

Why /api/v1/? URL versioning is the simplest way to make breaking API changes in the future without breaking existing clients. If I ever need to change the response format, I ship /api/v2/ and deprecate v1 gradually.

πŸ“– flask-cors docs | API versioning strategies

3.2 Model Serialization with to_dict()

Every model needed a to_dict() method to convert SQLAlchemy objects to JSON-serializable dictionaries. I chose manual serialization over libraries like Marshmallow because I only have 9 models and wanted full control:

# project/models.py β€” User.to_dict()
def to_dict(self) -> dict[str, Any]:
    return {
        "id": self.id,
        "username": self.username,
        "email": self.email,
        "about_me": self.about_me,
        "image_file": self.image_file,
        "confirmed": self.confirmed,
        "last_seen": (
            self.last_seen.isoformat()
            if self.last_seen else None
        ),
        "registered_on": (
            self.registered_on.isoformat()
            if self.registered_on else None
        ),
        "follower_count": self.followers.count(),
        "following_count": self.followed.count(),
    }

Key pattern: Nested serialization with opt-in. Workouts can optionally include their exercises, and exercises can include their sets:

# Workout.to_dict() β€” nested includes
def to_dict(self, include_exercises: bool = False):
    data = {
        "id": self.id,
        "title": self.title, ...
    }
    if include_exercises:
        data["exercises"] = [
            ex.to_dict(include_sets=True)
            for ex in self.exercises
                .order_by(Exercise.exercise_order).all()
        ]
    return data

3.3 JWT Authentication (Pre-Supabase)

Initially, I built custom JWT auth using PyJWT. This was a stepping stone β€” it let us test the API independently before migrating to Supabase:

# Original auth_utils.py (later replaced by Supabase)
def generate_api_token(user_id: int) -> str:
    payload = {
        "sub": str(user_id),
        "iat": datetime.now(timezone.utc),
        "exp": datetime.now(timezone.utc)
            + timedelta(hours=24),
    }
    return jwt.encode(
        payload,
        current_app.config["SECRET_KEY"],
        algorithm="HS256",
    )

Gotcha: PyJWT 2.12+ requires sub to be a string. I originally passed "sub": user_id (an int) and got InvalidSubjectError. The fix: "sub": str(user_id) on encode, int(sub) on decode.

3.4 Coexisting Auth Systems

The Flask app now had two authentication systems running side by side:

  • Session auth (Flask-Login): For the Jinja2 web frontend. Uses cookies, CSRF tokens, form-based login.
  • Token auth (JWT): For the API. Uses Authorization: Bearer <token> headers, no CSRF needed.

They don't conflict because they use completely different mechanisms. A request to /workouts uses cookies; a request to /api/v1/workouts uses a Bearer token.

3.5 The API Surface

I built 35 endpoints across 6 route modules:

ModuleEndpointsExamples
Auth8login, register, profile
Workouts6CRUD + toggle done
Templates5CRUD + use template
Exercises6CRUD + copy from user
Categories4CRUD
Social6explore, follow, messages

JSON conventions:

// Success (single item)
{"data": {"id": 1, "title": "..."}}

// Success (paginated list)
{
  "data": [...],
  "meta": {
    "page": 1,
    "per_page": 100,
    "total": 42,
    "has_next": false,
    "has_prev": false
  }
}

// Error
{"error": "Title is required."}

3.6 Testing

I wrote 85 API integration tests (398 total across the app), organized by module:

tests/integration/
  test_api_auth.py       β€” 17 tests
  test_api_workouts.py   β€” 21 tests
  test_api_exercises.py  β€” 14 tests
  test_api_categories.py β€” 10 tests
  test_api_social.py     β€” 10 tests

Test fixtures generated JWT tokens for authenticated requests:

# tests/conftest.py β€” API header fixture
@pytest.fixture
def api_headers(app, user):
    with app.app_context():
        token = generate_api_token(user.id)
        return {
            "Authorization": f"Bearer {token}",
            "Content-Type": "application/json",
        }

πŸ“– PyJWT docs | Flask testing


4. Restructuring into a Monorepo

Before adding the Next.js frontend, I needed to restructure the repo. Everything lived at the root:

# Before
calisthenics-progression/
  project/          # Flask app
  tests/
  requirements.txt
  app.py
  ...

I moved all Flask code into backend/ using git mv to preserve history:

# After
calisthenics-progression/
  backend/
    project/
    tests/
    requirements.txt
    app.py
  web/              # (coming in Phase 2b)
  VISION.md

Gotchas During Restructuring

1. Relative paths broke. The SQLite database path was relative (sqlite:///./app.db). After moving to backend/, Flask couldn't find the DB. Fix:

# project/config.py β€” before (broken)
SQLALCHEMY_DATABASE_URI = os.environ.get(
    "DATABASE_URL"
) or "sqlite:///" + os.path.join(basedir, "app.db")

# After (correct)
backend_dir = os.path.abspath(
    os.path.join(basedir, "..")
)
SQLALCHEMY_DATABASE_URI = os.environ.get(
    "DATABASE_URL"
) or "sqlite:///" + os.path.join(
    backend_dir, "instance", "app.db"
)

The same issue hit the logging directory β€” logs/ was created relative to the working directory, not to the project. Fixed by anchoring to __file__:

logs_dir = os.path.join(
    os.path.abspath(os.path.dirname(__file__)),
    "..", "logs",
)
os.makedirs(logs_dir, exist_ok=True)

2. Pre-commit couldn't find its config. .pre-commit- config.yaml was inside backend/ but pre-commit expects it at the repo root. Moved it back and updated the hooks to cd backend for pytest:

# .pre-commit-config.yaml
- id: flake8
  entry: flake8
  exclude: ^backend/migrations/
  args: ["--max-line-length=100", ...]

- id: pytest
  entry: bash -c 'cd backend && pytest'
  pass_filenames: false

3. Flask auto-discovery. Flask looks for app.py or wsgi.py by default. Our entry point was named calisthenics_progression.py. Renamed to app.py so flask run works without FLASK_APP config.

4. Production deployment. The Hetzner server's systemd service needed WorkingDirectory updated:

# /etc/systemd/system/calisthenics.service
WorkingDirectory=.../backend  # was: repo root

5. Phase 2a: Supabase Auth Migration

This was the most architecturally significant change: moving from "Flask handles all auth" to "Supabase handles auth, Flask just verifies tokens."

5.1 Adding supabase_uid to User

The User model keeps its integer primary key (all foreign keys reference it), but gains a UUID column that maps to Supabase's auth.users:

# project/models.py
class User(UserMixin, Base):
    id = db.Column(db.Integer, primary_key=True)
    supabase_uid = db.Column(
        db.String(36),
        unique=True,
        nullable=True,
        index=True,
    )
    # ... rest of model unchanged

Why keep the integer PK? Changing every foreign key (workouts, exercises, messages, followers, notifications) from integer to UUID would be a massive, risky migration. Instead, supabase_uid is a lookup column β€” the JWT's sub claim maps to it.

The Alembic migration was straightforward:

# migrations/versions/03260d9e792a_...
def upgrade():
    with op.batch_alter_table('user') as batch_op:
        batch_op.add_column(
            sa.Column('supabase_uid',
                      sa.String(length=36),
                      nullable=True)
        )
        batch_op.create_index(
            'ix_user_supabase_uid',
            ['supabase_uid'],
            unique=True,
        )

5.2 Rewriting auth_utils.py

The JWT verification was completely rewritten. Instead of decoding our own tokens, I verify Supabase-issued JWTs:

def _verify_supabase_jwt(token: str) -> dict | None:
    secret = current_app.config.get("SUPABASE_JWT_SECRET")
    try:
        payload = jwt.decode(
            token,
            secret,
            algorithms=["HS256"],
            audience="authenticated",
        )
        return payload
    except jwt.ExpiredSignatureError:
        return None
    except jwt.InvalidTokenError:
        return None

The audience parameter is critical. Supabase JWTs have "aud": "authenticated" β€” if you don't verify this, someone could potentially use an anon-role token to authenticate.

5.3 Auto-Provisioning Users

The most elegant part of the design: Flask auto-creates a User record the first time it sees a new Supabase UUID. No separate registration step needed in Flask:

def _get_or_create_user(payload: dict) -> User | None:
    supabase_uid = payload.get("sub")
    email = payload.get("email", "")
    confirmed = payload.get("email_confirmed_at") is not None

    # Look up existing user
    user = db.session.execute(
        db.select(User).filter_by(supabase_uid=supabase_uid)
    ).scalar_one_or_none()

    if user is not None:
        # Sync email and confirmed from Supabase
        if email and user.email != email:
            user.email = email
        if user.confirmed != confirmed:
            user.confirmed = confirmed
        db.session.commit()
        return user

    # Auto-create: username from email prefix
    base_username = email.split("@")[0]
    username = base_username
    suffix = 1
    while db.session.execute(
        db.select(User).filter_by(username=username)
    ).scalar_one_or_none() is not None:
        username = f"{base_username}_{suffix}"
        suffix += 1

    try:
        user = User(
            username=username, email=email,
            admin=False, confirmed=confirmed,
        )
        user.supabase_uid = supabase_uid
        db.session.add(user)
        db.session.commit()
        return user
    except IntegrityError:
        # Race condition: another request won
        db.session.rollback()
        return db.session.execute(
            db.select(User).filter_by(
                supabase_uid=supabase_uid
            )
        ).scalar_one_or_none()

Three important patterns here:

  1. Email sync: If a user changes their email in Supabase, Flask picks it up from the JWT automatically.
  2. Username collision handling: If john is taken, try john_1, john_2, etc.
  3. Race condition handling: If two requests arrive simultaneously for a new user, one will hit a unique constraint error. I catch IntegrityError, rollback, and re-query.

πŸ“– Supabase JWT structure | SQLAlchemy IntegrityError

5.4 Deleting Auth Routes

With Supabase handling auth, 6 of 8 API auth routes were deleted:

RouteStatusWhy
POST /auth/loginDeletedSupabase handles
POST /auth/registerDeletedSupabase handles
POST /auth/refreshDeletedSupabase handles
POST /auth/change-passwordDeletedSupabase handles
POST /auth/forgot-passwordDeletedSupabase handles
POST /auth/confirm-email/resendDeletedSupabase handles
GET /auth/profileKeptApp-specific data
PUT /auth/profileKeptUsername, about_me

The profile update was simplified too β€” email changes are no longer handled by Flask (Supabase owns email):

# auth_routes.py β€” only 2 routes remain
@bp.route("/auth/profile", methods=["GET"])
@api_login_required
def api_get_profile():
    return jsonify({
        "data": g.current_api_user.to_dict()
    }), 200

@bp.route("/auth/profile", methods=["PUT"])
@api_login_required
@api_check_confirmed
def api_update_profile():
    data = request.get_json(silent=True) or {}
    user = g.current_api_user

    if "username" in data:
        new_username = data["username"].strip()
        if new_username != user.username:
            # ... check uniqueness ...
            user.username = new_username

    if "about_me" in data:
        user.about_me = data["about_me"]

    db.session.commit()
    return jsonify({"data": user.to_dict()}), 200

5.5 Updating Tests for Supabase

All test fixtures were rewritten to generate Supabase-shaped JWTs instead of custom ones:

# tests/conftest.py
def _make_supabase_jwt(
    supabase_uid: str,
    email: str,
    confirmed: bool = True,
) -> str:
    now = datetime.now(timezone.utc)
    payload = {
        "sub": supabase_uid,
        "email": email,
        "aud": "authenticated",
        "role": "authenticated",
        "iat": now,
        "exp": now + timedelta(hours=1),
    }
    if confirmed:
        payload["email_confirmed_at"] = now.isoformat()
    else:
        payload["email_confirmed_at"] = None

    return pyjwt.encode(
        payload,
        TestConfig.SUPABASE_JWT_SECRET,
        algorithm="HS256",
    )

New tests were added for:

  • Auto-creating users on first API hit
  • Email syncing from JWT
  • Confirmed status syncing
  • Username collision handling
  • Expired/invalid/wrong-audience token rejection
  • Verifying deleted routes return 404

Final count: 400 tests passing, 95% coverage.


6. Phase 2b: Next.js Frontend (Milestone 0+1)

With the API and auth migration complete, I scaffolded the Next.js web frontend in the web/ directory.

6.1 Project Setup

npx create-next-app@latest web \
  --typescript --tailwind --eslint \
  --app --src-dir --import-alias "@/*"

cd web
npm install @supabase/supabase-js @supabase/ssr \
  @tanstack/react-query react-hook-form date-fns
DependencyPurpose
@supabase/supabase-jsSupabase client SDK
@supabase/ssrCookie-based auth for SSR
@tanstack/react-queryServer state caching
react-hook-formComplex form management
date-fnsDate formatting
tailwindcssUtility-first CSS

πŸ“– @supabase/ssr docs | React Query

6.2 The Supabase Client Split

Supabase needs two different clients depending on where the code runs:

Browser client β€” for Client Components ("use client"):

// src/lib/supabase/client.ts
import { createBrowserClient } from "@supabase/ssr";

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

Server client β€” for Server Components, Route Handlers, and Server Actions. Needs access to cookies:

// src/lib/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 {
            // Ignored in Server Components β€” proxy
            // handles session refresh instead.
          }
        },
      },
    }
  );
}

Why two clients? Server Components can read cookies but can't reliably write them (they run during HTML generation). Browser components can do both. The @supabase/ssr package handles this split by abstracting cookie access behind a simple interface.

6.3 Route Protection with proxy.ts

Next.js 16 introduced proxy.ts (replacing the deprecated middleware.ts). It runs on every request before the page renders β€” perfect for session refresh and route protection:

// src/proxy.ts
export async function proxy(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) {
          // Update both request and response cookies
          cookiesToSet.forEach(({ name, value }) =>
            request.cookies.set(name, value)
          );
          supabaseResponse = NextResponse.next({ request });
          cookiesToSet.forEach(({ name, value, options }) =>
            supabaseResponse.cookies.set(
              name, value, options
            )
          );
        },
      },
    }
  );

  // Refresh the session
  const { data: { user } } =
    await supabase.auth.getUser();

  // Unauthenticated β†’ login
  if (!user && !PUBLIC_ROUTES.includes(pathname)) {
    return NextResponse.redirect(
      new URL("/login", request.url)
    );
  }

  // Already logged in β†’ skip auth pages
  if (user && AUTH_ROUTES.includes(pathname)) {
    return NextResponse.redirect(
      new URL("/workouts", request.url)
    );
  }

  return supabaseResponse;
}

6.4 The Flask API Client

The API client wraps fetch() and automatically attaches the Supabase JWT:

// src/lib/api.ts
async function getAuthHeaders() {
  const supabase = createClient();
  const { data: { session } } =
    await supabase.auth.getSession();

  const headers: Record<string, string> = {
    "Content-Type": "application/json",
  };

  if (session?.access_token) {
    headers["Authorization"] =
      `Bearer ${session.access_token}`;
  }

  return headers;
}

export const api = {
  async get<T>(path: string, params?: Record<string, string>) {
    const headers = await getAuthHeaders();
    const url = new URL(`${API_URL}${path}`);
    // ... attach params, fetch, handle response
  },
  async post<T>(path, body?) { /* ... */ },
  async put<T>(path, body?) { /* ... */ },
  async delete<T>(path) { /* ... */ },
};

The beauty of this design: Every API call automatically gets the current Supabase JWT attached. No manual token passing. The Flask backend verifies it, looks up/creates the user, and serves the data.

6.5 Auth Pages

The auth pages talk directly to Supabase β€” Flask is not involved at all:

// src/app/(auth)/login/page.tsx
"use client";

export default function LoginPage() {
  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    const supabase = createClient();
    const { error } =
      await supabase.auth.signInWithPassword({
        email,
        password,
      });

    if (error) {
      setError(error.message);
      return;
    }

    router.push("/workouts");
    router.refresh();
  }
  // ... render form
}

Registration triggers Supabase's email confirmation flow:

// src/app/(auth)/register/page.tsx
const { error } = await supabase.auth.signUp({
  email,
  password,
  options: {
    emailRedirectTo:
      `${window.location.origin}/auth/callback`,
  },
});

After the user clicks the confirmation email, Supabase redirects to /auth/callback with a code that gets exchanged for a session:

// src/app/(auth)/auth/callback/route.ts
export async function GET(request: Request) {
  const code = searchParams.get("code");
  if (code) {
    const supabase = await createClient();
    await supabase.auth.exchangeCodeForSession(code);
    return NextResponse.redirect(`${origin}/workouts`);
  }
  return NextResponse.redirect(`${origin}/login`);
}

6.6 Route Groups for Layout Separation

Next.js App Router uses parenthesized folder names as route groups β€” they organize files without affecting the URL:

src/app/
  (auth)/          # No navbar, centered layout
    layout.tsx
    login/page.tsx       β†’ /login
    register/page.tsx    β†’ /register
  (app)/           # Has navbar, app shell
    layout.tsx
    workouts/page.tsx    β†’ /workouts

The (auth) layout centers the content with no navigation. The (app) layout wraps everything in a navbar with links to Workouts, Templates, Exercises, Explore, Messages, and Profile.

πŸ“– Next.js Route Groups | Tailwind CSS


7. Gotchas and Lessons Learned

Server Deployment Gotchas

Alembic migration heads conflict. The production server had a separate "initial migration" (bc3e6ec52bac) created by flask db init + flask db migrate directly on the server. Our local migrations had 7 incremental files with a different lineage. Fix:

# Delete the server-only migration
rm migrations/versions/bc3e6ec52bac_initial_migration.py
# Stamp to our local head
flask db stamp --purge 13234618113c
# Now upgrade works
flask db upgrade

Lesson: Always commit migrations to the repo. Never run flask db migrate on the server independently.

Environment variables live in two places. On the Hetzner server, the systemd service defines env vars for gunicorn. But flask CLI commands (like flask db upgrade) run in an SSH shell that doesn't have systemd's environment. Solution: keep a minimal .env on the server with DATABASE_URL and SUPABASE_JWT_SECRET.

Code Quality Gotchas

Flake8 vs auto-generated migrations. Alembic generates migration files with indentation that flake8 hates. Fix: exclude migrations at the pre-commit level (not flake8 level, because pre-commit passes individual file paths):

# .pre-commit-config.yaml
- id: flake8
  exclude: ^backend/migrations/

isort and black change files during commit. These formatters modify your files, so after the pre-commit hook runs, you need to git add the changes and commit again. This loop is annoying. Options: run them manually before committing, or use the --amend workflow.

Auth Migration Gotchas

Registering on the wrong port. With both Flask (5000) and Next.js (3000) running locally, it's easy to register on the Flask frontend by habit. The Flask registration creates a user without a supabase_uid. Always use localhost:3000 for the new auth flow.

Where are my Supabase users? They don't show up in the Supabase Table Editor. Supabase Auth users live in the auth.users schema, visible under Authentication β†’ Users in the dashboard.

Next.js 16 deprecation. middleware.ts was deprecated in favor of proxy.ts. Same code, different file name and function name. The deprecation warning only appeared at runtime, not during build.


8. Where We Are Now

What's Done

PhaseStatusKey Outcome
Phase 1: Flask REST APIDone35 endpoints, 400 tests
Monorepo restructureDonebackend/ + web/
Phase 2a: Supabase AuthDoneJWT verification, auto-create, 6 routes deleted
Phase 2b M0+M1: Next.js scaffold + authDoneWorking login/register/reset flow

What's Next

  • Milestone 2: App shell + workout list (first Flask API integration from Next.js)
  • Milestones 3-6: Exercises, categories, workout create/edit, social features, messages, profile
  • Phase 3: Expo mobile app (same API, same auth)
  • Phase 4: Remove Jinja2 templates, Flask-Login, Flask-Mail, password columns

Current Project Structure

calisthenics-progression/
  backend/
    project/
      api/             # 6 route modules, 29 endpoints
      auth/            # Legacy Jinja2 auth (Phase 4 removal)
      main/            # Legacy Jinja2 routes (Phase 4)
      models.py        # 9 models with supabase_uid
      config.py        # Supabase config vars
    tests/             # 400 tests, 95% coverage
    migrations/        # 8 Alembic migrations
    app.py             # Flask entry point
  web/
    src/
      app/
        (auth)/        # Login, register, forgot/reset pw
        (app)/         # App shell (workouts placeholder)
      lib/
        supabase/      # Browser + server clients
        api.ts         # Flask API client with JWT
      types/           # TypeScript interfaces
      proxy.ts         # Session refresh + route protection
    package.json       # Next.js 16, React 19, Supabase
  VISION.md            # Full 4-phase roadmap

Key Metrics

  • Backend: 400 tests, 95% coverage, 29 API endpoints
  • Frontend: 9 pages/routes, auth flow working end-to-end
  • Auth: Supabase-managed, zero auth code in Flask
  • Database: Supabase PostgreSQL (Pro plan) with supabase_uid column on User model

The foundation is solid. The hardest architectural decisions are made. From here, it's building pages.


*This journal documents the first Part of the migration.