Legacy sites
SharePoint 2016 to Supabase: an ISO 13485 QMS playbook
A Notified Body audit in nine weeks, a SharePoint 2016 farm on borrowed time, and an ISO 13485 trail that has to survive intact. Here is how we moved it.

It is a Tuesday in February. The quality manager at a 28-person medical device firm in Eindhoven has nine weeks until her Notified Body arrives for a surveillance audit. Her QMS lives on a SharePoint 2016 farm running on two Windows Server 2016 VMs in a closet behind reception. Microsoft ended mainstream support for SharePoint Server 2016 in July 2021 and extended support in July 2026. The IT contractor who built the farm has stopped answering email. The document approval workflow last worked reliably in 2022.
This is the story of how we got her off SharePoint and onto a Next.js, Supabase and signed-PDF stack without losing a single ISO 13485 audit attribute. It is also a playbook you can copy.
The audit-trail problem nobody warns you about
ISO 13485:2016 clause 4.2.5 says records must remain legible, identifiable and retrievable. In practice the Notified Body will pick a Risk Management File from three years ago and ask four questions in a row. Who approved version 4. When. Why was version 3 obsoleted. Where is the training record proving the approver was qualified to approve it.
SharePoint stores all of this. It just stores it in a haystack. Some attributes live in the document library item metadata. Some live in custom workflow tables that the original consultant added. Some live in Word document properties. Some live in the comments column of a SharePoint Designer workflow that nobody has touched since the consultant left.
The migration job is not move the files. It is move the chain of custody for every attribute the auditor might ask about, and prove the move did not alter anything.
Two weeks of inventory before any code
Before we wrote a line of TypeScript we built a spreadsheet. One row per attribute. One column per ISO 13485 clause that touches it. Where each attribute lives in SharePoint today, where it will live tomorrow, and how we will prove the value did not change in transit.
For a controlled document the attributes looked like this:
document_id,title,doc_type(SOP, WI, FRM, ...)current_version,previous_version,supersedes_ideffective_date,review_due_date,obsoletion_dateauthor_id,reviewer_ids,approver_idapproval_date,approval_reason,change_request_idtraining_required,training_role_idslinked_records(audit findings, CAPAs, change requests, deviations)
The clauses are the auditor's lens. If a clause has no mapping you have a gap. We found three. Two were buried in the description field of a SharePoint workflow. One was a Word macro nobody had run since 2020.
If you cannot point to a column for clause 4.2.4 c (approval reason) and clause 7.3.9 (design change reasons), the migration will fail the audit even if every file copies over without a byte changing.
The schema that keeps an auditor happy
We split the data model into three concerns. A documents table holds current state. A document_versions table is append-only and stores every published version with the SHA-256 of its signed PDF. An audit schema sits in its own namespace with no UPDATE or DELETE grants, not even to the Supabase service role.
create schema audit;
create table audit.events (
id bigserial primary key,
occurred_at timestamptz not null default now(),
actor_id uuid not null,
document_id uuid,
version_id uuid,
event_type text not null,
before jsonb,
after jsonb,
signed_sha256 text,
reason text
);
revoke update, delete on audit.events
from public, authenticated, service_role;
create or replace function audit.capture() returns trigger
language plpgsql security definer as $$
begin
insert into audit.events (
actor_id, document_id, event_type, before, after
) values (
coalesce(current_setting('app.actor', true)::uuid,
'00000000-0000-0000-0000-000000000000'),
coalesce(new.id, old.id),
tg_op,
case when tg_op in ('UPDATE','DELETE') then to_jsonb(old) end,
case when tg_op in ('INSERT','UPDATE') then to_jsonb(new) end
);
return coalesce(new, old);
end $$;
create trigger documents_audit
after insert or update or delete on public.documents
for each row execute function audit.capture();
Two details earn their keep. The revoke on UPDATE and DELETE means the audit table is genuinely append-only at the database layer, not just by convention in application code. And every write must run set_config('app.actor', user_uuid, true) at the start of the transaction, so the trigger never falls back to the zero UUID in production. We added a Postgres assertion in CI that fails any test which writes to documents without setting the actor first.
Signed PDFs as the record of truth
The signed PDF, not the database row, is the artefact of record. An auditor needs to open one file and see the content, the signatures, the approval reasons and the chain back to the previous version. The database is a fast index into those PDFs. If the database burned down tomorrow, the PDFs in object storage would still be a valid QMS.
We used PAdES B-LTA signatures (Long Term with Archive timestamp) backed by an eIDAS-qualified Time Stamping Authority. The signature stays verifiable decades from now even after the signing certificate expires. The signing code is straightforward Node.js:
import { SignPdf } from '@signpdf/signpdf';
import { P12Signer } from '@signpdf/signer-p12';
import { plainAddPlaceholder } from '@signpdf/placeholder-plain';
import { readFileSync } from 'node:fs';
const p12 = readFileSync(process.env.QMS_SIGNER_P12!);
const signer = new P12Signer(p12, {
passphrase: process.env.QMS_SIGNER_PASS!,
});
export async function signApproval(opts: {
pdf: Buffer;
reason: string;
approver: string;
}) {
const placeheld = plainAddPlaceholder({
pdfBuffer: opts.pdf,
reason: opts.reason,
contactInfo: opts.approver,
name: opts.approver,
location: 'Eindhoven, NL',
});
return new SignPdf().sign(placeheld, signer);
}
The reason field is not decoration. It is where the approver writes approved for release per CR-2026-014. Every PAdES viewer surfaces it. Auditors love it because it answers why in the same place as who and when.
Cutover without losing a single signature
The cutover ran on a Saturday morning. The plan had seven steps and we rehearsed it twice on a staging copy.
- Flip the SharePoint site to read-only by removing Contribute permissions from every group. No backup, no export at this stage. Read-only is the migration freeze.
- Run the extractor. For every document, pull every historical version with its full metadata, comments and any attached approval emails.
- For each historical version, generate a new PDF with the original content plus a signed migration appendix. The appendix records the SharePoint item ID, the original modified date, the migrating actor and a SHA-256 of the original blob.
- Insert into
document_versions. Never write todocumentsdirectly. The current row is computed from the latest version wherestate = 'effective'. - Reconcile. Count items per
doc_typein SharePoint versus Supabase. Count versions per item. Hash every blob on both sides. Any mismatch blocks the cutover. - Keep SharePoint live in read-only mode for 90 days. The team has a one-click show me this in SharePoint link on every document page during the window.
- Run a mock audit before booking the real one. We invited an external ISO 13485 consultant to spend a day trying to break the trail. He found two things. We fixed them.
Step three deserves a note. When we regenerated PDFs for historical versions we did not alter the original document content. The migration appendix is appended to the PDF as an incremental update, not merged into the original signature dictionary. Any original signature remains independently verifiable. The new countersignature proves the migration itself.
The auditor walkthrough
The Notified Body auditor will not log into Supabase. They sit next to the quality manager and ask show me SOP-014 version 3, who approved it, when was training delivered, and where is the change request that triggered version 4.
The Next.js UI exposes one screen per document with three tabs.
- Current. The signed PDF inline, signature panel open by default, training-status badge in the header.
- History. Every prior version downloadable, each row showing approver and reason inline.
- Trail. The immutable event log filtered to this document, with a deep link to the same view filtered by actor.
That is the whole UI. Every read of the Trail tab is itself logged, in case the auditor asks who has looked at this record. The auditor in our case spent eleven minutes on this screen across the full surveillance day. The rest of the time was conversation.
Three lessons we paid for
First, do not let anyone fix the old data during migration. One engineer wanted to correct a typo in a 2021 SOP title. The right answer is no. Migration preserves history. Corrections are change controls in the new system, with their own approval and signature.
Second, the signing certificate is the highest-value asset in the whole stack. Treat it like a production database root credential. The p12 lives in a hardware-backed secret store, the unlock passphrase in Supabase Vault, and rotation requires two-person approval with a documented change record.
Third, write the auditor walkthrough script before you write the Next.js UI. If the script reads naturally and answers every clause the auditor might raise, the UI almost writes itself. If you start with the UI you will build a beautiful document library and miss the part where the auditor needs to filter the event log by actor in under three clicks.
What this stack actually costs to run
The hardware bill went from two Windows Server VMs plus SQL Server CALs plus a SharePoint farm licence to a Supabase Pro project at €25 a month, a Vercel team seat, and a few euros a year in TSA timestamp credits. The eIDAS-qualified certificate is the only line item that costs real money, roughly €600 a year for a signing certificate from a qualified trust service provider. None of that is the point. The point is that on the new stack the quality manager can answer a clause-4.2.5 question in nine seconds. On the old stack it took her fourteen minutes and a phone call.
When we ran this migration for the Eindhoven team, our first countersignature broke PAdES Long Term validation because we re-signed inside the original signature dictionary instead of appending an incremental update, and restamping every historical PDF cost us a day. We now codify that gotcha and twenty others in how we approach legacy migration work.
If you are staring at your own SharePoint 2016 farm with an audit on the calendar, the smallest thing you can do today is the attribute spreadsheet. One row per attribute, one column per ISO clause. Two hours and a coffee. It tells you whether you are looking at a four-week job or a fourteen-week one.
Key takeaway
Your auditor does not care which stack holds the QMS. They care that every signature, version and approval reason can be reproduced cold.
FAQ
Is a Next.js plus Supabase stack defensible to a Notified Body for ISO 13485?
Yes, provided the records are signed PDFs and the audit trail is genuinely append-only at the database layer. The auditor cares about the chain of custody, not the brand of the database.
Do we need eIDAS-qualified signatures for an internal QMS?
ISO 13485 does not mandate qualified signatures, but PAdES B-LTA with a qualified TSA gives you signatures that stay verifiable after the signing certificate expires. That is worth the roughly 600 euro a year.
How long should we keep the old SharePoint farm running after cutover?
Ninety days in read-only mode is enough. Long enough to catch a missed attribute, short enough that nobody starts treating it as a live system again.
Can we migrate without freezing SharePoint writes?
Not safely. A live source means reconciliation never converges and the auditor cannot trust the hash count. A two-hour Saturday-morning freeze is cheaper than a failed audit.