Last updated
One way: SharePoint stays the system of record, and SPSync keeps a read-only copy of your lists current in Supabase.
Status: Shipped · product: SPSync for Relational Databases
Plenty of small systems began as a SharePoint list because it was the tool to hand: a product catalogue, a member directory, a request queue. When that data needs a proper app in front of it, Supabase provides the pieces the list API lacks, from generated REST and realtime APIs to row-level security and an ORM-friendly schema. SPSync keeps the Supabase tables current from the list, and SharePoint stays where the business edits.
Connecting to a Supabase project
Supabase exposes one database through three endpoints, and SPSync works with all three:
- Transaction pooler, port 6543 on the pooler host. A pooled connection is held for one transaction only, which makes it the most economical choice against Supabase’s connection cap. Supabase’s connection guide describes it for serverless and edge functions, which open many short-lived connections. SPSync uses it by default because the shared pooler works over IPv4 on every plan and a sync run is a short connect, load and disconnect job.
- Session pooler, port 5432 on the same pooler host, one pooled connection per client connection. Supabase’s guide points persistent backends on an IPv4-only network here.
- Direct, straight to Postgres. On Supabase this is IPv6 only unless your project
has the IPv4 add-on, and the add-on swaps the address over to IPv4 entirely.
Supabase’s guide sends migrations,
pg_dumpand backups to this endpoint.
The shared pooler is reachable over IPv4 on every plan, which matters when the sync runs
on an office network or a VM with no IPv6 route. Paid projects also get a dedicated
pooler on the database machine itself, which runs in transaction mode only. SPSync always
connects with TLS (sslmode=require). Supabase itself accepts plain connections to the
database and both poolers until you turn on SSL enforcement, which is off by default, as
its SSL enforcement guide
explains. Switch it on under Database settings unless another client still needs a plain
connection. Copy the pooler host from the project’s Connect dialog, since its prefix depends on where the
project was provisioned. The connection string, with the database password, goes in an
environment variable on the machine that runs the sync.
The sync writes to Supabase through the same PostgreSQL destination it uses for any server. What this page says about Supabase’s endpoints, grants and plans comes from Supabase’s documentation, so check the Connect dialog of your own project before the first run.
The one setting to get right: exposed schemas
Supabase grants the anon and authenticated roles full rights on new tables in the
public schema, and its Data API serves any table in an exposed schema to holders of
your publishable key. Combine those with a table that has row-level security switched
off and anyone with the key can read the list, or write to it. So decide where the sync
writes before the first run:
- A private schema. Set the replication’s
dataset_nameto a schema such assharepointand leave it out of the API’s exposed schemas. Your server code reads it with a database connection, and nothing reaches it through the Data API. The sync also createssharepoint_stagingfor the rows each run merges in; keep that one out of the exposed list too. - An exposed schema, with RLS on. If the app’s browser code should read the tables through the generated API, turn row-level security on for every replicated table and write a policy for each role that reads it.
New tables keep appearing as SharePoint changes. A multi-choice column added next month
becomes a new child table, and it starts with RLS off. Supabase’s Security Advisor flags
a table in public without RLS, so check it after any run that added a table.
Tables your app can use
Take a request queue with a Department lookup, a Requester person column, a Stage
choice and a multi-choice Tags column, on a site replicated with the prefix ops.
SPSync writes these tables:
| Table | Columns that matter to the app |
|---|---|
ops_requests | sp_item_id (primary key), title, stage, department_id, requester_user_id, sp_modified, sp_deleted_at |
ops_departments | The lookup’s target list, keyed on sp_item_id |
ops_requests__tags | sp_item_id, value, ordinal, one row per tag |
sp_users | id, display_name, email, login for every person named in any list |
Each table gets a primary key; the lookup and person columns are plain columns to join
on, so add constraints yourself if your app or its policies expect them. Deleted items
stay in the table with sp_deleted_at set, so the app decides whether to show them. Hard
delete is a per-replication setting in the command-line tool’s YAML config. New
SharePoint columns appear on the next run, removed ones stay, and a retyped column gets a
new _v2 column beside the old one.
An RLS policy that lets signed-in staff read live requests for their own department, and the query a dashboard page runs, look like this:
alter table sharepoint.ops_requests enable row level security;
create policy "staff read their department" on sharepoint.ops_requests for select to authenticated using ( sp_deleted_at is null and department_id = (select department_id from public.staff where user_id = auth.uid()) );
select r.title, r.stage, u.display_name as requester, string_agg(t.value, ', ') as tagsfrom sharepoint.ops_requests rleft join sharepoint.sp_users u on u.id = r.requester_user_idleft join sharepoint.ops_requests__tags t on t.sp_item_id = r.sp_item_idwhere r.sp_deleted_at is nullgroup by r.sp_item_id, r.title, r.stage, u.display_nameorder by r.sp_item_id desc;The public.staff table is yours: it maps a Supabase Auth user to a department, which
SharePoint cannot express as a row filter. Lookups and person columns arrive as keys, and
the policy and the query join on them.
Security for the app’s readers
Because SPSync’s role owns the tables it creates, row-level security does not block its
writes. If the sync connects as the postgres role, that role bypasses RLS in any case.
Turn RLS on for each replicated table and write policies for the roles your app signs
users in as, the same way your other tables are secured. If you set FORCE ROW LEVEL SECURITY, include a policy for the sync’s role too.
SharePoint item permissions do not travel with the data. A list item only some staff can see in SharePoint is a plain row in Supabase, and the policies you write are the only thing deciding who reads it.
Agents and the Supabase copy
An AI agent can work from these tables the same way the app does. Point a Postgres MCP
server at the session pooler with a login that can only SELECT the schema the sync
writes to, or the views you choose to share. spsync mcp serve, SPSync’s own read-only
MCP server, is another route. Keep the
agent’s login separate from postgres and from the service role, both of which bypass
row-level security, so the policies above apply to it as well. Rows from a document
library carry file names and paths; the file contents themselves are not copied.
Supabase limits to check first
- Database size. Each plan caps it, and the no-cost tier’s cap is small. A whole site with several libraries, plus soft-deleted rows, can reach it sooner than the list count suggests.
- Connections. Plans cap direct connections and pooler clients. The sync connects only while a run is in progress, so it rarely matters, but a busy app on the direct endpoint can leave it nothing.
- Paused projects. Supabase pauses inactive projects on the no-cost tier; paid plans are not paused. A paused project refuses the sync’s connection until you restore it from the dashboard.
- Identifiers. Postgres caps names at 63 bytes, and SPSync shortens long SharePoint names to fit.
Where it goes from here
The full story of an app built on SharePoint data starts here. The same tables serve as an application read replica for an existing app, feed a Power BI report, and support joins across lists in SQL.
Steps
- Sign in with delegated access, or have an admin grant
Sites.Selectedon the site so the sync runs unattended. - Create the target schema and decide whether it is exposed.
- Store the pooler connection string in an environment variable.
- Choose the lists or the whole site; the first run creates the tables and loads every item.
- Set a schedule, every few minutes up to hourly. Later runs read changes only, plus a reconcile, weekly by default, that checks every table against SharePoint.
- Turn RLS on and add policies, then connect the app.
Supabase plans cap database size and connections, billed by Supabase, so check both before replicating a large site.
Watch it run
Chapters are in the video's description.
Watch the walkthrough: the Northwind Analytics demo lists in SharePoint, connecting to Microsoft 365 in the desktop app, picking lists, the run into an empty Supabase Postgres database, and the replicated tables in Supabase Studio.
Watch the command-line version of this walkthrough.
Desktop, Azure or command line
All three editions run the same engine, write the same tables and use the same licence key, so the choice comes down to who looks after the sync.
Desktop app
For macOS and Windows. Sign in with Microsoft, tick the lists, choose a database and sync from your own machine on the schedule you set. Delegated sign-in needs no admin unless your tenant requires admin consent for apps.
In your Azure
SPSync in your Azure, a managed application for your own subscription, is deployed with you on a call while the Marketplace listing is prepared. Once deployed it is always on, so the sync keeps running with every laptop closed.
Command line
Every replication is a YAML file you can review and commit. Run it from a server, a container or a CI pipeline, and re-run it safely any time.
Frequently asked questions
Does SPSync work through Supabase's connection pooler?
Yes. SPSync defaults to the transaction pooler and can also use the session pooler or the direct connection. The default is our choice: the shared pooler is reachable over IPv4 on every Supabase plan, and a sync run connects, loads and disconnects, so holding a pooled connection only for each transaction suits it. Supabase's own guide describes transaction mode for serverless functions and the direct connection for migrations. The database library behind the sync sets the target schema as a connection startup option, and a run through the transaction pooler on a live Supabase project has not been tested yet. If the first run cannot connect, switch to the session pooler. SPSync connects with TLS in every mode.
Will row-level security get in the way of the sync?
No. SPSync creates the replicated tables, so its role owns them, and Postgres does not apply row-level security policies to a table's owner unless you force it. Add policies for the roles your app uses to read the tables.
Can an app use Supabase's generated APIs on the replicated tables?
Yes. Supabase's REST and realtime APIs work on them as on any other table in an exposed schema. If SPSync writes to a schema other than public, add that schema to the API's exposed schemas.
Talk to us about Supabase
SPSync is licensed per installation and per target family, with Standard Support included. Tell us which targets you need and how many machines, and we will size it with you. See how licensing works.
Set it up
Sign in to SharePoint with the SharePoint connection guide, then follow the PostgreSQL setup guide to create the database user and open the connection for Supabase. If the connection test fails, the troubleshooting guide lists the errors people hit and what fixes them.
Script it
Everything you can click, you can script. The CLI, YAML config and automation below reproduce this scenario exactly. See the CLI reference.
spsync auth login --client-id 00000000-0000-0000-0000-000000000000
spsync export-config --output replication.yaml --pipeline-name supabase --prefix supabase --destination-kind postgres --connection-string-env SPSYNC_DB_CONNECTION --dataset-name supabase --client-id 00000000-0000-0000-0000-000000000000 --site-url https://contoso.sharepoint.com/sites/example --all-lists
spsync doctor replication.yaml
spsync run replication.yaml pipeline_name: supabase
prefix: supabase
auth:
mode: delegated
client_id: 00000000-0000-0000-0000-000000000000
tenant_id: common
destination:
kind: postgres
connection_string_env: SPSYNC_DB_CONNECTION
dataset_name: supabase
scopes:
- site_url: https://contoso.sharepoint.com/sites/example
mode: entire_site
reconcile_interval_days: 7
create_reporting_views: false
Cron (PostgreSQL nightly):
# every night at 02:00
0 2 * * * cd /opt/spsync && SPSYNC_DB_CONNECTION="$(cat /etc/spsync/db.env)" uv run spsync run replication.yaml >> /var/log/spsync/run.log 2>&1 GitHub Actions:
# .github/workflows/spsync-run.yml
name: SPSync
on:
schedule:
- cron: "0 2 * * *"
workflow_dispatch: {}
jobs:
run:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
- run: uv sync
- run: uv run spsync run replication.yaml
env:
SPSYNC_DB_CONNECTION: ${{ secrets.SPSYNC_DB_CONNECTION }}
SPSYNC_LICENCE_KEY: ${{ secrets.SPSYNC_LICENCE_KEY }} Azure DevOps:
# azure-pipelines.yml
schedules:
- cron: "0 2 * * *"
branches: { include: [main] }
always: true
steps:
- script: uv sync
- script: uv run spsync run replication.yaml
env:
SPSYNC_DB_CONNECTION: $(SPSYNC_DB_CONNECTION)
SPSYNC_LICENCE_KEY: $(SPSYNC_LICENCE_KEY) Windows (PowerShell + Task Scheduler):
# Windows Task Scheduler action (see /docs/cli/automation/)
$env:SPSYNC_DB_CONNECTION = (Get-Content C:\spsync\db.env)
uv run spsync run C:\spsync\replication.yaml
schtasks /Create /SC DAILY /ST 02:00 /TN "SPSync" /TR "powershell -File C:\spsync\run.ps1" macOS (launchd):
# ~/Library/LaunchAgents/io.spsync.run.plist, then: launchctl load ~/Library/LaunchAgents/io.spsync.run.plist Secrets (licence key, connection strings) come from the environment, so never write them into the YAML. See scheduling and secrets.