Open Finapp
Development

Deployment

Deploy Finapp and docs to Vercel from a monorepo, with Supabase and PowerSync as the backend.

Finapp is a pnpm workspace monorepo. Both the app and docs deploy from the same repository as separate Vercel projects.

Vercel Setup

Create two projects in Vercel, both pointing to the same GitHub repository. Nuxt needs no special Vercel configuration beyond the per-project settings below - see Nuxt on Vercel and Vercel's Nuxt guide for the framework basics.

Settings split: file vs project. Most build settings live in each project's vercel.json (framework, buildCommand, installCommand, outputDirectory, ignoreCommand, env, rewrites). Root Directory cannot live in vercel.json because Vercel needs it to know where to look for the file. Set it once in the project's dashboard (Settings -> General -> Root Directory) or via the API:
TOKEN=$(jq -r .token "$HOME/Library/Application Support/com.vercel.cli/auth.json")
curl -X PATCH \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  "https://api.vercel.com/v9/projects/<project-name>?teamId=<team-id>" \
  -d '{"rootDirectory":"docs"}'

App Project

SettingValue
Root Directoryapp
Framework PresetNuxt.js
Build Commandpnpm build
Output Directory.output/public
Install Commandpnpm install

Add the following environment variables pointing at your hosted Supabase project and PowerSync service:

VariableValue
VITE_SUPABASE_URLYour Supabase project URL
VITE_SUPABASE_ANON_KEYYour Supabase publishable (anon) key
VITE_POWERSYNC_URLYour PowerSync service URL
Git-connected vs direct CLI deploy. The two-project setup here is Git-connected (pushes auto-deploy). For a standalone or throwaway environment (e.g. a finapp-dev project off a feature branch), skip the Git connection and deploy the working tree straight from the repo root: create/link a project (vercel link) with its Root Directory set to app, then vercel deploy --prod. The whole monorepo uploads from the root and Vercel builds app/ via the Root Directory setting.

Docs Project

SettingValue
Root Directorydocs
Framework PresetNuxt.js
Build Commandpnpm build
Output Directory.output/public

Ignored Build Step

To avoid unnecessary builds, configure Ignored Build Step in each Vercel project settings (Settings -> Git -> Ignored Build Step):

git diff --quiet HEAD^ HEAD -- . ':!docs'

This way the app won't redeploy when only docs change, and vice versa.

Backend: Supabase + PowerSync

The Nuxt SPA is statically hosted (Vercel above, but any static host serving .output/public after pnpm generate works). The backend services are separate:

  • Supabase - hosted Supabase project (cloud or self-hosted) provides Postgres, Auth, and the PostgREST/supabase-js write path.
  • PowerSync - a PowerSync service (cloud or self-hosted) handles offline-first sync by replicating from the Supabase Postgres via logical replication.

Set the three VITE_* env vars in Vercel to point at your production Supabase project and PowerSync instance. See Installation for the variable descriptions.

Supabase (production project)

  1. Create a Supabase project (dashboard, or supabase projects create), link the repo and push the schema (Supabase migration docs):
    Terminal
    cd app
    supabase projects create finapp --org-id <org-id> --region <region> --db-password <pw>
    supabase link --project-ref <project-ref>
    supabase db push        # applies supabase/migrations/ (tables, RLS, signup trigger)
    

    No IPv6? db push will fail. The direct connection db.<ref>.supabase.co:5432 is IPv6-only, so on a network without IPv6 db push errors with socket is not connected. Push over the IPv4 session pooler instead - copy its string from the dashboard's Connect tab (Session mode):
    Terminal
    supabase db push --db-url "postgresql://postgres.<ref>:<pw>@aws-1-<region>.pooler.supabase.com:5432/postgres"
    

    This page uses two different connections: the pooler (IPv4) for db push and queries from your machine, and the direct connection for PowerSync's logical replication below (PowerSync's own infrastructure has IPv6).
  2. Ensure the powersync publication exists for the synced tables - supabase db query runs the setup SQL against the linked project, no psql required:
    Terminal
    supabase db query --linked -f supabase/powersync_setup.sql
    # over the pooler a multi-statement file may hit the prepared-statement limit;
    # then run the publication's DROP and CREATE as separate `supabase db query "..."` calls
    

    Cloud vs self-hosted role. The script's powersync_role (REPLICATION + BYPASSRLS) is for self-hosted PowerSync. On Supabase Cloud the postgres role already has REPLICATION + BYPASSRLS (and a non-superuser postgres cannot create a REPLICATION role anyway), so PowerSync Cloud connects as postgres - there only the powersync publication matters.
  3. RLS policies and the user_settings signup trigger come with the migrations - nothing to configure manually.

Self-hosting Supabase instead of the cloud? The Finapp-specific steps are identical (apply the migrations, run powersync_setup.sql) - follow the self-hosting with Docker guide for the stack itself, then swap the *.supabase.co URL and JWKS endpoint for your instance's own.

PowerSync (production service)

Two options; both replicate from the Supabase Postgres and validate Supabase JWTs via JWKS:

  • PowerSync Cloud (managed): point the source database at the Supabase direct connection string (not the pooler - logical replication needs a direct connection), enable Supabase auth, and use the sync rules from app/powersync/config/sync-config.yaml. Do it in the dashboard (Supabase + PowerSync guide) or with the official PowerSync CLI (npm i -g powersync), which keeps the instance config as code:
    Terminal
    powersync login
    powersync init cloud                     # scaffolds powersync/service.yaml + sync-config.yaml
    # service.yaml: replication -> Supabase direct conn (username postgres);
    #   client_auth -> supabase: true + jwks_uri https://<ref>.supabase.co/auth/v1/.well-known/jwks.json
    # use app/powersync/config/sync-config.yaml as the sync rules
    powersync link cloud --instance-id <id>  # or --create --org-id <org> --project-id <project>
    POWERSYNC_DATABASE_PASSWORD=<pw> powersync deploy   # deploys service-config + sync-config
    

    A new PowerSync Cloud project ships with Production and Development instances already created - deploy to one of those instead of creating a third. Projects are created in the dashboard; the CLI manages instances and config, not projects.
  • Self-hosted: app/powersync/ (docker-compose + config/service.yaml) is the template, but it is local-dev only - its credentials are committed for a localhost stack. For production: move the replication URI, the bucket-storage Postgres URI, and the API token into secrets; point client_auth.jwks_uri at https://<project-ref>.supabase.co/auth/v1/.well-known/jwks.json and remove allow_local_jwks; give the service its own Postgres for bucket storage (it auto-migrates its schema); terminate TLS in front of port 8080. See the self-hosting guide and instance configuration reference for the full config.

VITE_POWERSYNC_URL is the public URL of that service.

Exchange rates (fetch-rates)

Currency conversion needs the rates table populated. A Supabase edge function (app/supabase/functions/fetch-rates/) does that: it pulls two USD-based sources - Coinbase (no API key, ~all fiat + crypto, the base layer) and Open Exchange Rates (better fiat precision, overlaid on top) - merges them into one map and writes a single source = 'merged' row per day. The client reads only the latest row (SELECT * FROM rates ORDER BY date DESC LIMIT 1), so the merge happens at write time. Writes use the auto-injected service_role - rates has a global read policy but no client INSERT policy.

  1. Deploy the function:
    Terminal
    cd app
    supabase functions deploy fetch-rates
    
  2. OER key (optional). Set it as a function secret to add precise fiat; without it the function runs Coinbase-only (still ~630 currencies, crypto included). Get a free App ID at openexchangerates.org:
    Terminal
    supabase secrets set OPEN_EXCHANGE_RATES_KEY=<app_id>
    
  3. Daily schedule. The migration supabase/migrations/*_schedule_fetch_rates.sql enables pg_cron + pg_net and schedules fetch-rates-daily at 06:00 UTC; it ships with supabase db push (same IPv6/pooler caveat as above). The cron reads the function URL and anon key from Vault secrets - kept out of the migration so it stays portable - so set them once per project (until they exist the scheduled run is a harmless no-op):
    Terminal
    supabase db query --linked "select vault.create_secret('https://<ref>.supabase.co', 'project_url')"
    supabase db query --linked "select vault.create_secret('<anon-key>', 'anon_key')"
    
Seed it once.supabase functions has no invoke subcommand - trigger a run with a plain POST so rates is populated before the first scheduled run:
Terminal
curl -X POST "https://<ref>.supabase.co/functions/v1/fetch-rates" \
  -H "Authorization: Bearer <anon-key>"

Google Sign-In (Production)

In production the Google provider is configured in the hosted Supabase dashboard, not in config.toml (that file only drives the local stack).

  1. In the Google Cloud Console, create (or reuse) an OAuth 2.0 Client ID and add your project's auth callback as an authorized redirect URI:
    https://<your-supabase-project>.supabase.co/auth/v1/callback
    
  2. In the Supabase dashboard: Authentication -> Providers -> Google, enable it and paste the client id/secret.
  3. In Authentication -> URL Configuration, set Site URL to your deployed app origin (e.g. https://finapp.ilko.me) and add it (with a /** wildcard) to Redirect URLs, so the OAuth return to /login is allowed.
Without the dashboard. Steps 2-3 can be done in a single Management API call (needs a Supabase access token):
Terminal
curl -X PATCH "https://api.supabase.com/v1/projects/<ref>/config/auth" \
  -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" -H "Content-Type: application/json" \
  -d '{"external_google_enabled":true,"external_google_client_id":"<id>","external_google_secret":"<secret>","site_url":"https://<your-app>","uri_allow_list":"https://<your-app>/**"}'
Do not use supabase config push for a hosted project - it pushes the whole config.toml, whose site_url is the local http://localhost:3050, overwriting the remote URL config. The Management API changes only the fields you send.

Google needs no extra app env vars - it reuses VITE_SUPABASE_URL / VITE_SUPABASE_ANON_KEY. Supabase issues its own JWT regardless of the sign-in provider, so PowerSync keeps validating it via JWKS unchanged.

Migrating to a New Prod Project

Standing up a fresh prod project alongside an existing environment (promoting a dev/local dataset, or cutting over from a different backend) adds three steps the generic setup above doesn't cover: importing existing data, pointing Vercel at the new project, and flipping the deploy branch.

Importing existing data (userId remap)

Four tables are per-user: categories, wallets, trns, user_settings. rates is global - don't copy it (it's populated by fetch-rates). The copy is Postgres -> Postgres (the schema is identical across environments), rewriting userId to the destination project's auth uid.

The destination userId is the user's auth uid in the new project, which only exists after they sign in once there. So finish and do a first login (a preview deploy works), read the uid, then import.

Stream each table with userId rewritten in the SELECT. For user_settings remap id too (it equals the uid):

Terminal
SRC_UID=<source-auth-uid>
DST_UID=<new-prod-auth-uid>

# categories - wallets/trns follow the same pattern (full column list, userId substituted).
psql "<source-conn>" -c "\copy (
  select id, '$DST_UID' as \"userId\", name, color, icon, \"parentId\",
         \"showInLastUsed\", \"showInQuickSelector\", \"updatedAt\"
  from public.categories where \"userId\" = '$SRC_UID'
) to stdout" \
| psql "<prod-pooler-conn>" -c "\copy public.categories from stdin"

Writes to the prod DB go over the IPv4 session pooler (same IPv6 caveat as db push) as postgres / service_role, which bypasses RLS. The rows are inert until they match a real uid - once userId equals the signed-in user's uid, PowerSync streams them to the client on the next sync.

Pointing Vercel at the new project

If your local .vercel is linked to a different project (e.g. a dev one), re-link before setting prod env, otherwise the vars land on the wrong project:

Terminal
vercel link            # choose the prod project

Set VITE_SUPABASE_URL / VITE_SUPABASE_ANON_KEY / VITE_POWERSYNC_URL to the prod values and remove any legacy-backend vars. Don't trigger a prod rebuild yet if the default branch still serves the old stack - wait for the branch cutover below, or prod builds the old code against the new env.

Branch cutover (git-connected prod)

When the new stack lives on a feature branch and prod auto-deploys from the default branch, advance the default branch instead of force-pushing:

  1. Archive the current default branch so the old stack stays recoverable:
    Terminal
    git branch <archive> <default>      # e.g. git branch convex main
    git push origin <archive>
    
  2. Fast-forward the default branch to the feature tip (no force-push when the feature is a direct descendant - check with git rev-list --left-right --count <default>...<feature>, the left count must be 0):
    Terminal
    git checkout <default>
    git merge --ff-only <feature>
    git push origin <default>
    

The push triggers the prod Vercel build on the new stack. Rollback is cheap: re-point the default branch at the archive branch (git push origin <archive>:<default> --force-with-lease), or instant-rollback to the previous deploy in Vercel.

Premium (Finapp Premium)

Finapp Premium lives in a separate repository (finapp-premium) and extends the base app as a Nuxt layer.

Vercel Settings

SettingValue
Root Directory.
Framework PresetNuxt.js
Build Commandnpx nuxt prepare base/app && pnpm generate
Output Directory.output/public
Install Commandpnpm install

No FINAPP_BASE_PATH environment variable is needed on Vercel. The default path is ./base/app, which points to the checked-out base submodule.

How It Works

  • nuxt.config.ts uses FINAPP_BASE_PATH env var to resolve the base layer
  • Default (CI/Vercel): ./base/app - the app/ subdirectory inside the base Git submodule
  • base/ is a Git submodule pointing to the main branch of ilkome/finapp
  • npx nuxt prepare base/app generates base/app/.nuxt/tsconfig.json, which Vite needs to resolve TypeScript config from the submodule layer
  • Local dev: set FINAPP_BASE_PATH=../mono/app in .env to use the local monorepo with hot reload
Vercel initializes Git submodules during checkout. The base submodule repository must be public.

Local Development

Set FINAPP_BASE_PATH in .env to point to the monorepo app directory:

.env
FINAPP_BASE_PATH=../mono/app

Git Submodule

The submodule should point to the main branch of ilkome/finapp:

Terminal
git submodule add -b main https://github.com/ilkome/finapp.git base

After a fresh clone:

Terminal
git submodule update --init