Building a Secure Submission Pipeline with Jekyll, Supabase Edge Function , and Postgres
This is a three part writeup, documenting the first successful backend ingestion path for the DataInsideData™ Builder Showcase experiment.
The goal was pretty simple but important:
Static Jekyll form
↓
JavaScript payload
↓
Supabase Edge Function
↓
Validation and security checks
↓
Postgres insert
↓
Manual review queue
The result:
A project submission form on the DataInsideData™ site successfully submitted a payload through a Supabase Edge Function and inserted a new row into a Supabase Postgres table.
That means the platform now has a working static-site-to-backend intake pipeline.
Project Context
The Builder Showcase experiment is designed to help builders turn GitHub projects into clearer, recruiter-ready project pages.
The current MVP focuses on project submission and review.
The first backend milestone was:
Can a public Jekyll page submit structured project data into Supabase Postgres securely enough for MVP testing?
The answer is now yes.
System Components
The working setup includes:
| Layer | Tool / Component | Purpose |
|---|---|---|
| Static frontend | Jekyll + Minimal Mistakes | Public site and submit page |
| Form logic | Browser JavaScript | Collect form data and send payload |
| Backend endpoint | Supabase Edge Function | Validate request and insert row |
| Runtime | Deno | Edge Function execution environment |
| Database | Supabase Postgres | Store project submissions |
| DB admin/testing | pgAdmin, Supabase Dashboard, notebooks | Inspect and test records |
| Local CLI tooling | npx supabase |
Init, link, create functions, deploy, set secrets |
| Notebook tooling | Python, subprocess, psycopg2, SQLAlchemy |
Test database connection and CLI commands |
High-Level Architecture
%%{init: {
"theme": "base",
"themeVariables": {
"background": "#0b1220",
"lineColor": "#3b82f6",
"fontSize": "18px",
"fontFamily": "Arial",
"textColor": "#ffffff",
"primaryTextColor": "#ffffff",
"secondaryTextColor": "#e5e7eb"
}
}}%%
flowchart TB
%% GLOBAL NODE STYLE
classDef default fill:#111827,stroke:#3b82f6,stroke-width:2px,color:#ffffff;
A["Builder / Submitter"] --> B["Jekyll Submit Page"]
B --> C["JavaScript FormData Payload"]
C --> D["Supabase Edge Function<br/>submit-builder-project"]
D --> E["Validation + Security Checks"]
E --> F["Supabase Postgres"]
F --> G["builder_project_submissions"]
G --> H["Manual Review Queue"]
H --> I["Approved Project Published Later"]
High Level Architectural flow diagram depicting submission, review, and approval pipeline.
Supabase Project Setup
After creating the Supabase project from Supabase console, the table in Postgres, database table.
A quick SQL check using either jupyter or PgAdmin confirmed the remote database/backend:
select
current_database() as database_name,
current_schema() as current_schema,
version() as postgres_version;
The result showed:
postgres | public | PostgreSQL 17.6 ...
That confirmed the hosted Postgres instance was live.
Choosing the Database Connection Style
For notebook and local database testing, the Session Pooler connection was chosen.
This was useful because the workflow was not a long-running production server. It was:
VS Code notebook
↓
connect
↓
run SELECT / INSERT / UPDATE
↓
inspect
↓
close connection
The .env file stored database values locally:
SUPABASE_DB_HOST="the-session-pooler-host"
SUPABASE_DB_PORT="5432"
SUPABASE_DB_NAME="postgres"
SUPABASE_DB_USER="postgres.project-ref"
SUPABASE_DB_PASSWORD="password"
SUPABASE_DB_SSLMODE="require"
Important:
Do not commit .env files.
Do not put database passwords in browser JavaScript.
Creating the Submission Table
The main MVP table is:
public.builder_project_submissions
It acts as:
intake queue + review queue + GitHub metadata staging table
Core schema:
create table if not exists builder_project_submissions (
id uuid primary key default gen_random_uuid(),
first_name text not null,
last_name text not null,
submitter_email text not null,
github_username text,
repo_url text not null,
project_title text not null,
project_category text,
project_description text,
project_tags text[],
live_url text,
readme_url text,
owns_or_has_permission boolean not null default false,
wants_public_showcase boolean not null default true,
status text not null default 'submitted',
admin_notes text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
Later, GitHub and README fields were added:
alter table builder_project_submissions
add column if not exists repo_owner text,
add column if not exists repo_name text,
add column if not exists repo_branch text,
add column if not exists repo_path text,
add column if not exists readme_markdown text,
add column if not exists readme_fetch_status text not null default 'pending',
add column if not exists readme_fetch_error text,
add column if not exists readme_fetched_at timestamptz;
Connecting from a VS Code Notebook
The first Python database helper used psycopg2.
This ensures notebook path is known + the reading in of necessary credentials.
import os
import psycopg2
from dotenv import load_dotenv
from pathlib import Path
PROJECT_ROOT = Path.cwd().parent if Path.cwd().name == "notebooks" else Path.cwd()
load_dotenv(PROJECT_ROOT / ".env", override=True)
def get_supabase_connection():
conn = psycopg2.connect(
host=os.getenv("SUPABASE_DB_HOST"),
port=os.getenv("SUPABASE_DB_PORT", "5432"),
dbname=os.getenv("SUPABASE_DB_NAME", "postgres"),
user=os.getenv("SUPABASE_DB_USER"),
password=os.getenv("SUPABASE_DB_PASSWORD"),
sslmode=os.getenv("SUPABASE_DB_SSLMODE", "require"),
)
return conn
A simple test query confirmed the notebook could talk to the remote Supabase database.
conn = get_supabase_connection()
try:
with conn.cursor() as cur:
cur.execute("select current_database(), current_schema(), version();")
print(cur.fetchone())
finally:
conn.close()
Connection Management Lesson
Supabase connection limits make connection discipline important.
The rule:
Open one connection.
Run planned queries.
Commit once.
Rollback if needed.
Close the connection.
A clean pattern:
conn = get_supabase_connection()
try:
with conn.cursor() as cur:
cur.execute("select current_database(), current_schema();")
print(cur.fetchone())
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
This avoids leaving notebook connections open.
Review Status Constraint
The review workflow uses a controlled status list.
Allowed values:
submitted
reviewing
approved
published
needs_changes
rejected
archived
This constraint allows for data entry integrity:
do $$
begin
if not exists (
select 1
from pg_constraint
where conname = 'builder_project_submissions_status_check'
) then
alter table builder_project_submissions
add constraint builder_project_submissions_status_check
check (
status in (
'submitted',
'reviewing',
'approved',
'published',
'needs_changes',
'rejected',
'archived'
)
);
end if;
end $$;
It prevents messy values like:
done
approved!
in progress
publish
README Fetch Status Constraint
The README fetch workflow is separate from the review workflow.
Allowed values:
pending
fetched
not_found
failed
skipped
Same reason for this constraint as above one:
do $$
begin
if not exists (
select 1
from pg_constraint
where conname = 'builder_project_submissions_readme_fetch_status_check'
) then
alter table builder_project_submissions
add constraint builder_project_submissions_readme_fetch_status_check
check (
readme_fetch_status in (
'pending',
'fetched',
'not_found',
'failed',
'skipped'
)
);
end if;
end $$;
Indexes for Optimization
create index if not exists idx_builder_project_submissions_status
on builder_project_submissions(status);
create index if not exists idx_builder_project_submissions_created_at
on builder_project_submissions(created_at desc);
create index if not exists idx_builder_project_submissions_repo_url
on builder_project_submissions(repo_url);
These support:
- review queues
- recent submissions
- repo lookup
- README fetch filtering
- GitHub metadata querying
updated_at Trigger
A trigger was created to automatically update updated_at whenever a row changes.
create or replace function set_updated_at()
returns trigger as $$
begin
new.updated_at = now();
return new;
end;
$$ language plpgsql;
drop trigger if exists trg_builder_project_submissions_updated_at
on builder_project_submissions;
create trigger trg_builder_project_submissions_updated_at
before update on builder_project_submissions
for each row
execute function set_updated_at();
This means normal updates like:
update builder_project_submissions
set status = 'reviewing'
where id = '...';
automatically update updated_at.
Row Level Security
Row Level Security was enabled:
alter table builder_project_submissions enable row level security;
The browser does not write directly to the table.
The safe MVP path is:
Browser form
↓
Edge Function
↓
server-side insert
↓
Postgres table
No direct public insert policy was added for anon.
Check out Part 2 here.
Data Inside Data™.
Tech Hands, a Science Mind, and a Heart for Community.