Build a Secure FastAPI Portfolio

Deploy a FastAPI portfolio with Google sign-in, Supabase RLS, and messaging.

Introduction

30 Second Summary

Potential clients and recruiters often decide whether to keep reading within a few moments. Your work needs a clear home that remains useful after you share the link.

In this project, you will build a responsive portfolio CMS with FastAPI. The finished app uses Supabase for secure data storage before running publicly on Render.

What You'll Build

You will share a live portfolio where visitors can browse your work while your private creator view manages projects and messages.

By the end of this project, you'll have:

  • A responsive public portfolio that keeps your About, Skills, Projects, Resume, and Contact sections readable on desktop and mobile.
  • A creator-only workspace where Google OAuth unlocks project creation, editing, and deletion for your approved account. Backend JWT validation plus Postgres Row Level Security reject every other account.
  • A shareable live portfolio where visitors can view your PDF resume or submit a private message. You can sign in to read those messages from your inbox.
  • Secret Mission: Save creator replies beside visitor messages so each answer persists after a reload.

Are there any prerequisites?

You should be comfortable with Python, HTML, CSS, JavaScript, and SQL.

You also need Windows with Cursor, Git, GitHub, Supabase access, and a PDF resume ready.

Before We Start

Before the hands-on work begins, this step helps you commit to a public portfolio with a secure creator CMS. Your plan includes the work you want to attract, the first three projects you will feature, and the tools you already have ready.

Set Up the Advanced Build Workspace

Your portfolio plan is ready. The next challenge is giving the build a dependable home on your computer.

Several Python packages power this project. Pinning each package version keeps dependency drift from changing the app during the sprint.

You will create that workspace in Cursor. You will also prepare the account access and local settings file used by later steps.

In this step, get ready to:
  • Create the local workspace structure with its configuration files.
  • Install the four pinned Python dependencies.
  • Sign in to Google Auth Platform and Render.
Create the workspace structure

The fastapi-portfolio folder keeps every part of the application together. Its empty templates and static folders reserve clear homes for the files you add in the next step.

  • Press the Windows key to open the search bar.
  • Type Cursor into the search bar.
  • Press Enter to open Cursor.
  • Select File from Cursor's top menu.
  • Select Open Folder.
  • Select Desktop in the folder picker.
  • Select New folder.
  • Type fastapi-portfolio as the folder name.

The folder picker now has the workspace name ready. Finish creating it so Cursor can use it as the project location.

  • Press Enter to create the folder.
  • Select the fastapi-portfolio folder.
  • Select Select Folder to open the workspace.

Seeing a workspace trust prompt?

Cursor may ask whether you trust the files in this folder. Confirm that you trust the workspace so Cursor can use its editor features and integrated terminal.

  • Select the New Folder icon beside fastapi-portfolio in the Explorer sidebar.
  • Type templates as the folder name.
  • Press Enter to create the folder.
  • Select the New Folder icon again.
  • Type static as the folder name.
  • Press Enter to create the folder.

You should see the empty templates and static folders beneath fastapi-portfolio in the Explorer sidebar.

  • Select the New File icon beside fastapi-portfolio in the Explorer sidebar.
  • Type .env.example as the file name.
  • Press Enter to create the file.
  • Add the environment placeholders to .env.example by pasting:
SUPABASE_URL=https://your-project-ref.supabase.co
SUPABASE_KEY=your-publishable-key

What does this file define?

  • The environment variables give the application named places to read its Supabase connection settings.
  • The SUPABASE_URL value will hold the base URL for your Supabase project.
  • The SUPABASE_KEY value will hold the project's publishable key.
  • The example file contains placeholders that document the required settings.
  • Save .env.example by pressing Ctrl+S.

You should see .env.example listed beside the two folders in the Explorer sidebar.

Missing the example file?

  • Check that the file sits directly inside fastapi-portfolio.
  • Confirm that the file name begins with a full stop.
  • Remove any extra file extension added to the name.

help me fix my missing .env.example file

  • Select the New File icon beside fastapi-portfolio.
  • Type .gitignore as the file name.
  • Press Enter to create the file.
  • Add the repository exclusions to .gitignore by pasting:
.env
__pycache__/
*.pyc

What does this file protect?

  • Git reads .gitignore before tracking files.
  • The .env entry keeps your local settings outside future commits.
  • The remaining entries exclude generated Python cache files.
  • Save .gitignore by pressing Ctrl+S.

You should see .gitignore listed directly inside fastapi-portfolio.

Is the local settings file still trackable?

  • Confirm that the first line contains exactly .env.
  • Check that .gitignore has no hidden text extension.
  • Save the file after correcting its entries.

help me check my .gitignore file

  • Select the New File icon beside fastapi-portfolio.
  • Type requirements.txt as the file name.
  • Press Enter to create the file.
  • Add the dependency pins to requirements.txt by pasting:
fastapi==0.156.4
uvicorn==0.54.0
Jinja2==3.1.6
python-dotenv==1.2.4

Why pin these versions?

  • The fastapi==0.156.4 pin keeps the web framework consistent.
  • The uvicorn==0.54.0 pin fixes the local application server version.
  • The Jinja2==3.1.6 pin fixes the template engine version.
  • The python-dotenv==1.2.4 pin fixes how local environment settings are loaded.
  • Save requirements.txt by pressing Ctrl+S.

You should now see three configuration files beside the two empty folders in the Explorer sidebar.

Does a dependency line look different?

  • Check the capitalization of Jinja2.
  • Confirm that every package uses two equals signs before its version.
  • Remove any blank dependency lines containing spaces.

help me compare my dependency pins

✔️ Awesome, I've got everything!

Your workspace structure is ready. Save all three files before installing the packages.

ⓧ I'd like to double check the full code

  • Compare your complete .env.example file with this reference:
SUPABASE_URL=https://your-project-ref.supabase.co
SUPABASE_KEY=your-publishable-key

Environment file checkpoint

This complete file contains the two placeholder settings required by the application.

  • Compare your complete .gitignore file with this reference:
.env
__pycache__/
*.pyc

Git exclusions checkpoint

This complete file excludes the local settings file plus generated Python cache files.

  • Compare your complete requirements.txt file with this reference:
fastapi==0.156.4
uvicorn==0.54.0
Jinja2==3.1.6
python-dotenv==1.2.4

Dependency file checkpoint

This complete file pins all four packages used by the project.

The templates directory should be empty. The static directory should also be empty.

Pin the Python dependencies

The app depends on four packages with fixed versions. Each package owns one part of the local web stack.

What are the four packages?

  • FastAPI provides the web application framework.
  • Uvicorn runs the application on your computer.
  • Jinja2 renders Python data into HTML templates.
  • python-dotenv loads local settings from .env.
  • Select Terminal from Cursor's top menu.
  • Select New Terminal.
  • Add all four pinned packages to your current Python installation by running these commands:
python -m pip install fastapi==0.156.4
python -m pip install uvicorn==0.54.0
python -m pip install Jinja2==3.1.6
python -m pip install python-dotenv==1.2.4

What do these commands install?

  • Each command uses the pip module connected to your current Python installation.
  • Each double-equals pin requests the exact package release listed in requirements.txt.
  • Running the installs separately makes the package causing any failure easy to identify.

You should see package installation output for each command. The terminal should return to its prompt after the fourth package finishes.

Did a package fail to install?

  • Confirm that Cursor's terminal can run the python command.
  • Check the failed package name against requirements.txt.
  • Run the failed install command again after confirming your internet connection.

help me diagnose my pinned package installation

Prepare local settings and service access

The example settings file documents the required values. A local .env copy gives the application somewhere to read your project-specific values later.

The copy contains placeholders at this stage. You can prepare it without exposing a live key.

  • Select .env.example in the Explorer sidebar.
  • Select File from Cursor's top menu.
  • Select Save As.
  • Replace the file name with .env.
  • Select Save.

You should see both .env.example and .env in the Explorer sidebar. The .gitignore rule keeps the local copy outside future Git commits.

Did the example file disappear?

  • Confirm that Save As created .env inside fastapi-portfolio.
  • Recreate .env.example from the full-code checkpoint if it was renamed.
  • Keep both files at the same level as requirements.txt.

help me restore both environment files

These sign-ins prepare access for authentication and deployment. This step creates no cloud resources.

You should reach the signed-in Clients workspace in Google Auth Platform. This confirms that you can configure the OAuth client in a later step.

  • Open the Render Dashboard in your browser.
  • Complete the sign-in flow with your preferred account.

You should reach the signed-in Render dashboard. This confirms that your deployment account is ready.

Before you run the final check, which four imports do you expect the pinned workspace to load?

  • Verify all four dependencies from Cursor's terminal by running:
python -c "import fastapi, uvicorn, jinja2, dotenv; print('dependencies ready')"

What should I see?

Python imports all four packages before it reaches the print statement. You should see dependencies ready in the terminal.

Is an import missing?

  • Read the terminal output to identify the package import that failed.
  • Match that import to its pinned install command from the previous substep.
  • Run the matching install command again in the same terminal.

help me fix the failed dependency check

You have the foundation locked in: the workspace is organized, the dependency versions are fixed, and both cloud accounts are accessible. Next up, you will turn this workspace into a responsive FastAPI portfolio.

Render the Portfolio

Your Cursor workspace now has the folders and pinned dependencies needed for a FastAPI application. The next milestone is a portfolio you can load in a browser.

A visible server-rendered template gives you a working baseline before database and authentication complexity appears. You will use Jinja to turn temporary Python dictionaries into project cards.

In this step, get ready to:
  • Create the FastAPI application and render its home route with Jinja.
  • Build the portfolio structure with temporary project cards.
  • Style the page for wide and narrow browser widths.
Create the server-rendered route

The home route receives a browser request and returns an HTML template. Temporary project dictionaries let Jinja render real cards before Supabase becomes the source of truth.

  • Click the New File icon beside the fastapi-portfolio folder in Cursor's Explorer sidebar.
  • Enter main.py as the file name.
  • Add the FastAPI application by pasting this code into main.py:
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates

app = FastAPI(title="Portfolio CMS")
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")


@app.get("/", response_class=HTMLResponse)
async def home(request: Request):
    projects = [
        {
            "id": 1,
            "title": "Secure Portfolio CMS",
            "summary": "FastAPI, Google sign-in, Supabase RLS, messaging, and deployment.",
            "repo_url": "https://github.com/your-github-username/fastapi-portfolio",
        },
        {
            "id": 2,
            "title": "Python Developer Portfolio",
            "summary": "A full-stack Python developer portfolio",
            "repo_url": "",
        },
    ]
    return templates.TemplateResponse(request=request, name="index.html", context={
        "projects": projects, "supabase_url": "", "supabase_key": "",
        "creator_email": "", "resume_url": "",
    })

What Does This Code Do?

  • The app object creates the web application that Uvicorn serves.
  • The /static mount makes files from the static folder available to the browser.
  • The templates object connects FastAPI to the templates folder.
  • The home() route passes temporary project dictionaries into index.html.
  • Save main.py.
  • Open Cursor's integrated terminal from the top menu.

Before you start the server, what do you expect the terminal to confirm when FastAPI loads successfully?

  • Start the local application by running this command:
uvicorn main:app

What Does This Command Do?

The uvicorn command imports app from main.py. It keeps the terminal occupied while the local server accepts browser requests.

You will see startup output confirming that the server is running. Keep this terminal process active while you build the page.

Server Fails to Start?

Confirm that main.py sits directly inside fastapi-portfolio. Also confirm that the terminal is using that same folder.

Check the import named in the terminal error against the first four lines of main.py.

help me diagnose why my FastAPI application does not start

✔️ Awesome, I've got everything!

Your FastAPI process is running from main.py. Keep its terminal active for the browser checks ahead.

ⓧ I'd like to double check the full code

Compare your current main.py with this complete version:

from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates

app = FastAPI(title="Portfolio CMS")
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")


@app.get("/", response_class=HTMLResponse)
async def home(request: Request):
    projects = [
        {
            "id": 1,
            "title": "Secure Portfolio CMS",
            "summary": "FastAPI, Google sign-in, Supabase RLS, messaging, and deployment.",
            "repo_url": "https://github.com/your-github-username/fastapi-portfolio",
        },
        {
            "id": 2,
            "title": "Python Developer Portfolio",
            "summary": "A full-stack Python developer portfolio",
            "repo_url": "",
        },
    ]
    return templates.TemplateResponse(request=request, name="index.html", context={
        "projects": projects, "supabase_url": "", "supabase_key": "",
        "creator_email": "", "resume_url": "",
    })
Build the portfolio page

Jinja evaluates the template on the server before sending HTML to the browser. Its project loop creates one card for each dictionary supplied by home().

  • Click the templates folder in Cursor's Explorer sidebar.
  • Click the New File icon beside the templates folder.
  • Enter index.html as the file name.
  • Add the portfolio structure by pasting this code into templates/index.html:
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="description" content="A full-stack Python developer portfolio">
  <title>Python Developer Portfolio</title>
  <link rel="stylesheet" href="{{ url_for('static', path='/styles.css') }}">
</head>
<body data-supabase-url="{{ supabase_url }}" data-supabase-key="{{ supabase_key }}" data-creator-email="{{ creator_email }}">
<header><a class="brand" href="#top">Python Portfolio</a><nav><a href="#about">About</a><a href="#skills">Skills</a><a href="#projects">Projects</a><a href="#resume">Resume</a><a href="#contact">Contact</a></nav><button id="sign-in">Creator sign in</button><button id="sign-out" class="creator-only secondary">Sign out</button></header>
<main id="top">
  <section class="hero"><p class="eyebrow">Python developer</p><h1>I build useful products with clean APIs and secure data flows.</h1><p>FastAPI renders this portfolio, Supabase stores it, and Google sign-in protects creator tools.</p><a class="button-link" href="#projects">See the work</a></section>
  <section id="about"><p class="eyebrow">About</p><h2>Full-stack thinking, Python first</h2><p>Replace this with your background, strengths, and target role.</p></section>
  <section id="skills"><p class="eyebrow">Skills</p><h2>Tools I use to ship</h2><div class="skills"><span>Python</span><span>FastAPI</span><span>PostgreSQL</span><span>Supabase</span><span>REST APIs</span><span>OAuth</span><span>HTML</span><span>CSS</span><span>JavaScript</span></div></section>
  <section id="projects"><p class="eyebrow">Projects</p><h2>Selected builds</h2><div class="grid">
    {% for project in projects %}<article class="card" data-project-id="{{ project.id }}" data-title="{{ project.title }}" data-summary="{{ project.summary }}" data-repo-url="{{ project.repo_url }}"><h3>{{ project.title }}</h3><p>{{ project.summary }}</p>{% if project.repo_url %}<a href="{{ project.repo_url }}" target="_blank" rel="noreferrer">View repository</a>{% endif %}<div class="creator-only actions"><button class="edit-project secondary">Edit</button><button class="delete-project danger">Delete</button></div></article>{% else %}<p>No projects yet.</p>{% endfor %}
  </div></section>
  <section id="resume"><p class="eyebrow">Resume</p><h2>Experience at a glance</h2><p><a class="button-link" href="{{ resume_url }}" target="_blank" rel="noreferrer">View PDF</a> <a href="{{ resume_url }}?download=resume.pdf">Download resume</a></p><iframe src="{{ resume_url }}" title="Resume PDF"></iframe></section>
  <section id="contact"><p class="eyebrow">Contact</p><h2>Start a conversation</h2><form id="contact-form"><label>Name<input name="name" maxlength="80" required></label><label>Email<input name="email" type="email" maxlength="200" required></label><label>Message<textarea name="body" maxlength="1200" required></textarea></label><button>Send message</button></form></section>
  <section id="creator-panel" class="creator-only panel"><p class="eyebrow">Creator workspace</p><h2>Manage projects</h2><form id="project-form"><label>Title<input name="title" maxlength="80" required></label><label>Summary<textarea name="summary" maxlength="400" required></textarea></label><label>GitHub URL<input name="repo_url" type="url" placeholder="https://github.com/you/project"></label><button>Add project</button></form><h2>Private inbox</h2><div id="inbox" class="inbox"></div></section>
  <p id="status" class="status" role="status" aria-live="polite"></p>
</main>
<footer>Built with FastAPI, Supabase, and Render.</footer>
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2.118.0"></script><script src="{{ url_for('static', path='/app.js') }}"></script><script src="{{ url_for('static', path='/replies.js') }}"></script>
</body></html>

How Does the Template Work?

  • The navigation links point to the About, Skills, Projects, Resume, and Contact sections on the same page.
  • The Jinja loop reads each item in projects and produces a project card.
  • The url_for() calls generate browser paths for files mounted under /static.
  • The creator controls and inbox remain hidden until later authentication behavior activates them.
  • Save templates/index.html.
  • Confirm that index.html appears inside the templates folder in Cursor's Explorer sidebar.

Template File in the Wrong Place?

Confirm that Cursor shows index.html nested under templates. A file beside that folder cannot be found by the configured template directory.

help me check my FastAPI template path

The template also requests two browser files from the existing static folder. Small placeholders keep those paths ready for the interaction work added later.

  • Click the static folder in Cursor's Explorer sidebar.
  • Click the New File icon beside the static folder.
  • Enter app.js as the file name.
  • Add the initial browser helpers by pasting this code into static/app.js:
const statusBox=document.querySelector("#status");
const status=message=>statusBox.textContent=message;
const formObject=form=>Object.fromEntries(new FormData(form).entries());

What Do These Helpers Prepare?

The first helper targets the page's status area. The other helpers prepare status messages and form values for the browser interactions that build on this page.

  • Save static/app.js.
  • Confirm that app.js appears inside the static folder in Cursor's Explorer sidebar.
  • Click the New File icon beside the static folder.
  • Enter replies.js as the file name.
  • Add the Secret Mission launchpad by pasting this code into static/replies.js:
// Secret Mission launchpad. Replies are read-only in the core project.

Why Create This File Now?

The template already loads replies.js. This launchpad gives that script path a valid file while keeping reply editing outside the core portfolio.

  • Save static/replies.js.
  • Confirm that app.js and replies.js appear inside the static folder.

Before you load the page, predict how the temporary dictionaries from main.py will appear inside the Projects section.

  • Open http://127.0.0.1:8000 in your browser.

You will see the About, Skills, Projects, Resume, and Contact sections as plain browser content. The Projects section includes cards generated from the temporary Python dictionaries.

Page Shows an Error?

Return to the terminal running Uvicorn and look for the file named in the latest error. Confirm that index.html is inside templates.

Check that app.js and replies.js are inside static.

help me diagnose my FastAPI portfolio page error

✔️ Awesome, I've got everything!

The browser now receives a server-rendered portfolio with temporary project cards. Keep the local page open for the styling check.

ⓧ I'd like to double check the full code

Compare your three new browser files with these complete versions:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="description" content="A full-stack Python developer portfolio">
  <title>Python Developer Portfolio</title>
  <link rel="stylesheet" href="{{ url_for('static', path='/styles.css') }}">
</head>
<body data-supabase-url="{{ supabase_url }}" data-supabase-key="{{ supabase_key }}" data-creator-email="{{ creator_email }}">
<header><a class="brand" href="#top">Python Portfolio</a><nav><a href="#about">About</a><a href="#skills">Skills</a><a href="#projects">Projects</a><a href="#resume">Resume</a><a href="#contact">Contact</a></nav><button id="sign-in">Creator sign in</button><button id="sign-out" class="creator-only secondary">Sign out</button></header>
<main id="top">
  <section class="hero"><p class="eyebrow">Python developer</p><h1>I build useful products with clean APIs and secure data flows.</h1><p>FastAPI renders this portfolio, Supabase stores it, and Google sign-in protects creator tools.</p><a class="button-link" href="#projects">See the work</a></section>
  <section id="about"><p class="eyebrow">About</p><h2>Full-stack thinking, Python first</h2><p>Replace this with your background, strengths, and target role.</p></section>
  <section id="skills"><p class="eyebrow">Skills</p><h2>Tools I use to ship</h2><div class="skills"><span>Python</span><span>FastAPI</span><span>PostgreSQL</span><span>Supabase</span><span>REST APIs</span><span>OAuth</span><span>HTML</span><span>CSS</span><span>JavaScript</span></div></section>
  <section id="projects"><p class="eyebrow">Projects</p><h2>Selected builds</h2><div class="grid">
    {% for project in projects %}<article class="card" data-project-id="{{ project.id }}" data-title="{{ project.title }}" data-summary="{{ project.summary }}" data-repo-url="{{ project.repo_url }}"><h3>{{ project.title }}</h3><p>{{ project.summary }}</p>{% if project.repo_url %}<a href="{{ project.repo_url }}" target="_blank" rel="noreferrer">View repository</a>{% endif %}<div class="creator-only actions"><button class="edit-project secondary">Edit</button><button class="delete-project danger">Delete</button></div></article>{% else %}<p>No projects yet.</p>{% endfor %}
  </div></section>
  <section id="resume"><p class="eyebrow">Resume</p><h2>Experience at a glance</h2><p><a class="button-link" href="{{ resume_url }}" target="_blank" rel="noreferrer">View PDF</a> <a href="{{ resume_url }}?download=resume.pdf">Download resume</a></p><iframe src="{{ resume_url }}" title="Resume PDF"></iframe></section>
  <section id="contact"><p class="eyebrow">Contact</p><h2>Start a conversation</h2><form id="contact-form"><label>Name<input name="name" maxlength="80" required></label><label>Email<input name="email" type="email" maxlength="200" required></label><label>Message<textarea name="body" maxlength="1200" required></textarea></label><button>Send message</button></form></section>
  <section id="creator-panel" class="creator-only panel"><p class="eyebrow">Creator workspace</p><h2>Manage projects</h2><form id="project-form"><label>Title<input name="title" maxlength="80" required></label><label>Summary<textarea name="summary" maxlength="400" required></textarea></label><label>GitHub URL<input name="repo_url" type="url" placeholder="https://github.com/you/project"></label><button>Add project</button></form><h2>Private inbox</h2><div id="inbox" class="inbox"></div></section>
  <p id="status" class="status" role="status" aria-live="polite"></p>
</main>
<footer>Built with FastAPI, Supabase, and Render.</footer>
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2.118.0"></script><script src="{{ url_for('static', path='/app.js') }}"></script><script src="{{ url_for('static', path='/replies.js') }}"></script>
</body></html>
const statusBox=document.querySelector("#status");
const status=message=>statusBox.textContent=message;
const formObject=form=>Object.fromEntries(new FormData(form).entries());
// Secret Mission launchpad. Replies are read-only in the core project.
Style and test the responsive layout

Responsive design lets the same page adapt to different screen widths. The stylesheet uses a three-column layout on wider screens and switches to one column below 760px.

  • Click the New File icon beside the static folder.
  • Enter styles.css as the file name.
  • Add the responsive design by pasting this code into static/styles.css:
:root{color-scheme:dark;--bg:#0b1020;--panel:#151c31;--text:#eef2ff;--muted:#a8b1ce;--accent:#7dd3fc;--line:#2a3554;--danger:#fb7185;font-family:Inter,system-ui,sans-serif}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--bg);color:var(--text);line-height:1.6}a{color:var(--accent)}button,input,textarea{font:inherit}header{position:sticky;top:0;z-index:2;display:flex;align-items:center;gap:1rem;padding:1rem 5vw;background:#0b1020ee;border-bottom:1px solid var(--line)}.brand{margin-right:auto;color:var(--text);font-weight:800;text-decoration:none}nav,.actions{display:flex;gap:.75rem;flex-wrap:wrap}nav a{color:var(--muted);text-decoration:none}main{width:min(1100px,92vw);margin:auto}section{padding:4rem 0;border-bottom:1px solid var(--line)}.hero{min-height:70vh;display:grid;align-content:center}h1{max-width:850px;font-size:clamp(2.5rem,7vw,5.5rem);line-height:1.02;margin:.3rem 0 1rem}h2{font-size:clamp(1.8rem,4vw,3rem)}.eyebrow{color:var(--accent);letter-spacing:.14em;text-transform:uppercase;font-size:.78rem;font-weight:800}button,.button-link{border:0;border-radius:.7rem;padding:.7rem 1rem;background:var(--accent);color:#07111f;font-weight:800;text-decoration:none;cursor:pointer}.secondary{background:transparent;color:var(--text);border:1px solid var(--line)}.danger{background:transparent;color:var(--danger);border:1px solid var(--danger)}.skills,.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:1rem}.skills span,.card,form,.message-card{padding:1.2rem;border:1px solid var(--line);border-radius:1rem;background:var(--panel)}.card{display:flex;flex-direction:column;gap:.6rem}.card p{color:var(--muted);flex:1}form{display:grid;gap:1rem;max-width:720px}label{display:grid;gap:.35rem;font-weight:700}input,textarea{width:100%;border:1px solid var(--line);border-radius:.6rem;padding:.8rem;background:#0d1426;color:var(--text)}textarea{min-height:8rem}iframe{width:100%;min-height:620px;border:1px solid var(--line);border-radius:1rem;background:white}.creator-only{display:none}body.creator .creator-only{display:flex}body.creator section.creator-only{display:block}.inbox{display:grid;gap:1rem}.message-card{display:grid;gap:.5rem}.message-card p{margin:0;white-space:pre-wrap}.status{position:fixed;right:1rem;bottom:1rem;max-width:420px;padding:.8rem;background:var(--panel);border-radius:.7rem}.status:empty{display:none}footer{padding:2rem;text-align:center;color:var(--muted)}@media(max-width:760px){header{flex-wrap:wrap}nav{order:3;width:100%;overflow:auto}.skills,.grid{grid-template-columns:1fr}iframe{min-height:460px}}

How Does the Responsive Design Work?

  • The color variables keep the dark palette consistent across the page.
  • The .skills and .grid selectors use three columns on wider screens.
  • The media query changes both grids to one column on narrow screens.
  • The hidden .creator-only elements preserve space for the authenticated workspace added later.
  • Save static/styles.css.
  • Refresh http://127.0.0.1:8000 in your browser.

The page now has a dark background, a sticky navigation header, styled project cards, and structured forms. Your server-rendered portfolio now looks like a finished interface.

Styles Missing From the Page?

Confirm that the file is named styles.css inside the static folder. Check that the template still contains the stylesheet url_for() call in its head.

Refresh the browser after saving the stylesheet. An unsaved file keeps the plain layout in place.

help me find why my FastAPI static stylesheet is not loading

✔️ Awesome, I've got everything!

Your stylesheet is saved and the browser displays the dark responsive design.

ⓧ I'd like to double check the full code

Compare your complete static/styles.css with this version:

:root{color-scheme:dark;--bg:#0b1020;--panel:#151c31;--text:#eef2ff;--muted:#a8b1ce;--accent:#7dd3fc;--line:#2a3554;--danger:#fb7185;font-family:Inter,system-ui,sans-serif}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--bg);color:var(--text);line-height:1.6}a{color:var(--accent)}button,input,textarea{font:inherit}header{position:sticky;top:0;z-index:2;display:flex;align-items:center;gap:1rem;padding:1rem 5vw;background:#0b1020ee;border-bottom:1px solid var(--line)}.brand{margin-right:auto;color:var(--text);font-weight:800;text-decoration:none}nav,.actions{display:flex;gap:.75rem;flex-wrap:wrap}nav a{color:var(--muted);text-decoration:none}main{width:min(1100px,92vw);margin:auto}section{padding:4rem 0;border-bottom:1px solid var(--line)}.hero{min-height:70vh;display:grid;align-content:center}h1{max-width:850px;font-size:clamp(2.5rem,7vw,5.5rem);line-height:1.02;margin:.3rem 0 1rem}h2{font-size:clamp(1.8rem,4vw,3rem)}.eyebrow{color:var(--accent);letter-spacing:.14em;text-transform:uppercase;font-size:.78rem;font-weight:800}button,.button-link{border:0;border-radius:.7rem;padding:.7rem 1rem;background:var(--accent);color:#07111f;font-weight:800;text-decoration:none;cursor:pointer}.secondary{background:transparent;color:var(--text);border:1px solid var(--line)}.danger{background:transparent;color:var(--danger);border:1px solid var(--danger)}.skills,.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:1rem}.skills span,.card,form,.message-card{padding:1.2rem;border:1px solid var(--line);border-radius:1rem;background:var(--panel)}.card{display:flex;flex-direction:column;gap:.6rem}.card p{color:var(--muted);flex:1}form{display:grid;gap:1rem;max-width:720px}label{display:grid;gap:.35rem;font-weight:700}input,textarea{width:100%;border:1px solid var(--line);border-radius:.6rem;padding:.8rem;background:#0d1426;color:var(--text)}textarea{min-height:8rem}iframe{width:100%;min-height:620px;border:1px solid var(--line);border-radius:1rem;background:white}.creator-only{display:none}body.creator .creator-only{display:flex}body.creator section.creator-only{display:block}.inbox{display:grid;gap:1rem}.message-card{display:grid;gap:.5rem}.message-card p{margin:0;white-space:pre-wrap}.status{position:fixed;right:1rem;bottom:1rem;max-width:420px;padding:.8rem;background:var(--panel);border-radius:.7rem}.status:empty{display:none}footer{padding:2rem;text-align:center;color:var(--muted)}@media(max-width:760px){header{flex-wrap:wrap}nav{order:3;width:100%;overflow:auto}.skills,.grid{grid-template-columns:1fr}iframe{min-height:460px}}

Before you narrow the browser, how do you expect the three-column Skills and Projects grids to change?

  • Resize the browser window until its content area is narrower than 760px.
  • Scroll through the About, Skills, Projects, Resume, and Contact sections.

You will see the Skills and Projects grids collapse into single columns. The navigation wraps cleanly while every portfolio section remains readable.

Your FastAPI and Jinja portfolio is running locally with responsive sections and temporary project cards. Next, you will move those cards into Supabase and test the write barrier that protects creator actions.

Connect Supabase and Enforce RLS

Your local FastAPI portfolio already renders five responsive sections. Its project cards still disappear whenever you change the temporary Python dictionaries.

Hard-coded projects cannot be managed after deployment. This step moves that content into Supabase and protects writes with Row Level Security.

In this step, get ready to:
  • Create the portfolio tables with database-enforced access policies.
  • Connect FastAPI to the Supabase Data REST API.
  • Attempt a protected write while signed out to expose the security barrier.
Create the protected data layer

The database needs to distinguish public portfolio activity from creator-only activity. Postgres grants define the available operations. RLS policies decide which signed-in identity may use them.

  • Use your browser to sign in to Supabase.
  • Select the Supabase project that will store your portfolio data.
  • Open the SQL Editor for that project.
  • Return to Cursor.
  • Create schema.sql inside the fastapi-portfolio folder by using the new-file control in the Explorer sidebar.
  • Add the table definitions by pasting this first chunk:
create table if not exists public.projects (
  id bigint generated by default as identity primary key,
  title text not null check (char_length(title) between 1 and 80),
  summary text not null check (char_length(summary) between 1 and 400),
  repo_url text not null default '',
  created_at timestamptz not null default now()
);
create table if not exists public.messages (
  id bigint generated by default as identity primary key,
  name text not null check (char_length(name) between 1 and 80),
  email text not null check (char_length(email) between 3 and 200),
  body text not null check (char_length(body) between 1 and 1200),
  reply text,
  created_at timestamptz not null default now()
);
alter table public.projects enable row level security;
alter table public.messages enable row level security;

What does this SQL do?

  • The projects table stores each public project card.
  • The messages table stores private visitor messages.
  • The length checks reject empty or oversized content before it reaches the portfolio.
  • RLS enables row-by-row access decisions for both tables.
  • Save schema.sql.
  • Copy the current contents of schema.sql.
  • Return to the Supabase SQL Editor.
  • Paste the SQL into a new query.
  • Execute the query.
  • Open the project table list to confirm that projects exists.
  • Confirm that messages also exists.

The two tables are now visible in Supabase. They have RLS enabled before any public access is granted.

Tables missing after execution?

Confirm that the query includes both closing table parentheses. A missing closing character prevents the remaining statements from running.

Check the SQL Editor result for the first line that names an error.

help me diagnose my Supabase schema error

RLS begins from a deny-by-default position. The next rules grant only the operations required by the public portfolio.

  • Return to schema.sql in Cursor.
  • Append the grants below the RLS statements:
revoke all on table public.projects, public.messages from anon, authenticated;
grant select on table public.projects to anon, authenticated;
grant insert, update, delete on table public.projects to authenticated;
grant insert on table public.messages to anon;
grant select, update on table public.messages to authenticated;
grant usage, select on sequence public.projects_id_seq to authenticated;
grant usage, select on sequence public.messages_id_seq to anon;

How do the grants divide access?

  • Public visitors receive read access to project cards.
  • Public visitors receive insert access for contact messages.
  • Authenticated sessions receive project write access.
  • Authenticated sessions receive inbox read access.
  • Save schema.sql.
  • Replace the SQL Editor query with the full current contents of schema.sql.
  • Execute the query again.

The query completes successfully. Supabase now has explicit table permissions ready for the row policies.

A grant only opens the door to an operation. The policies below inspect the verified email claim before allowing creator activity.

  • Record the Google address that owns the portfolio here: your Google email address.
  • Return to schema.sql.
  • Append the policies and starter project:
drop policy if exists "Public reads projects" on public.projects;
create policy "Public reads projects" on public.projects for select to anon, authenticated using (true);
drop policy if exists "Creator inserts projects" on public.projects;
create policy "Creator inserts projects" on public.projects for insert to authenticated
with check ((select auth.jwt() ->> 'email') = 'your-email@example.com');
drop policy if exists "Creator updates projects" on public.projects;
create policy "Creator updates projects" on public.projects for update to authenticated
using ((select auth.jwt() ->> 'email') = 'your-email@example.com')
with check ((select auth.jwt() ->> 'email') = 'your-email@example.com');
drop policy if exists "Creator deletes projects" on public.projects;
create policy "Creator deletes projects" on public.projects for delete to authenticated
using ((select auth.jwt() ->> 'email') = 'your-email@example.com');
drop policy if exists "Visitors send messages" on public.messages;
create policy "Visitors send messages" on public.messages for insert to anon with check (true);
drop policy if exists "Creator reads messages" on public.messages;
create policy "Creator reads messages" on public.messages for select to authenticated
using ((select auth.jwt() ->> 'email') = 'your-email@example.com');
drop policy if exists "Creator updates messages" on public.messages;
create policy "Creator updates messages" on public.messages for update to authenticated
using ((select auth.jwt() ->> 'email') = 'your-email@example.com')
with check ((select auth.jwt() ->> 'email') = 'your-email@example.com');

insert into public.projects (title, summary, repo_url)
select 'Secure Portfolio CMS', 'FastAPI, Google sign-in, Supabase RLS, messaging, and deployment.',
'https://github.com/your-github-username/fastapi-portfolio'
where not exists (select 1 from public.projects);

How do the policies enforce ownership?

Public project reads and visitor message inserts are deliberately open. Creator writes require an authenticated JWT whose email matches the address in the policy.

The final insert gives the portfolio one database-backed project card to render immediately.

  • Use Cursor's find-and-replace tool in schema.sql.
  • Replace every your-email@example.com occurrence with your Google email address.
  • Save schema.sql.
  • Replace the SQL Editor query with the completed contents of schema.sql.
  • Execute the completed query.
  • Open the projects table.
  • Confirm that the Secure Portfolio CMS row is present.

That is the database boundary in place. Public reads remain available while creator operations now depend on a verified identity.

Connect FastAPI to Supabase

The application needs the project URL for API requests. It also needs the publishable key for public access that remains governed by RLS.

  • Return to the Supabase project settings in your browser.
  • Locate the API connection details for the project.
  • Copy the project URL.
  • Return to Cursor.
  • Create .env inside the fastapi-portfolio folder by using the new-file control in the Explorer sidebar.
  • Add the environment variable structure below:
SUPABASE_URL=https://your-project-ref.supabase.co
SUPABASE_KEY=your-publishable-key

Why keep these values in an environment file?

The application reads deployment-specific configuration from .env. Your existing .gitignore keeps that populated file out of Git.

The publishable key is designed for public clients. RLS remains responsible for protecting the data behind it.

  • Replace https://your-project-ref.supabase.co with the project URL you copied.
  • Return to the Supabase API connection details.
  • Copy the publishable key.
  • Return to .env.
  • Replace your-publishable-key with the copied publishable key.
  • Save .env.

Keep the populated file private

Confirm that .env still appears in .gitignore. Do not paste the populated file into a screenshot or commit.

help me verify that Git ignores my environment file

The backend now needs one request helper for the Supabase Data REST API. It also needs validation helpers so untrusted form data cannot flow directly into database requests.

  • Open main.py from the Cursor Explorer sidebar.
  • Replace its current contents with the following six chunks in order.
  • Keep the file unsaved until all six chunks are in place.
  • Start with the imports and application configuration:
import asyncio
import json
import os
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.request import Request as URLRequest, urlopen

from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates

load_dotenv()
SUPABASE_URL = os.environ["SUPABASE_URL"].rstrip("/")
SUPABASE_KEY = os.environ["SUPABASE_KEY"]
CREATOR_EMAIL = "your-email@example.com"
RESUME_URL = f"{SUPABASE_URL}/storage/v1/object/public/resumes/resume.pdf"

app = FastAPI(title="Portfolio CMS")
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")

What does this configuration do?

  • Python loads the values from .env before the application starts.
  • The trailing slash is removed from SUPABASE_URL so API paths join cleanly.
  • CREATOR_EMAIL becomes the backend ownership check.
  • RESUME_URL prepares the public storage location used in a later step.
  • Replace your-email@example.com with your Google email address.
  • Continue with the Supabase request helper:


def supabase_request(path, method="GET", payload=None, token=None):
    data = None if payload is None else json.dumps(payload).encode()
    headers = {"apikey": SUPABASE_KEY, "Authorization": f"Bearer {token or SUPABASE_KEY}", "Accept": "application/json"}
    if data is not None:
        headers["Content-Type"] = "application/json"
    request = URLRequest(f"{SUPABASE_URL}{path}", data=data, headers=headers, method=method)
    try:
        with urlopen(request, timeout=12) as response:
            body = response.read().decode()
            return json.loads(body) if body else None
    except HTTPError as error:
        detail = error.read().decode() or error.reason
        raise HTTPException(error.code, detail) from error
    except URLError as error:
        raise HTTPException(502, "Supabase is unavailable") from error

Why use one request helper?

Every database call needs the publishable key. Protected requests also need the signed-in user's access token.

The helper preserves Supabase response codes as FastAPI errors. Connection failures become a clear backend availability response.

  • Add the JSON and text validation helpers next:


async def json_payload(request):
    try:
        payload = await request.json()
    except (json.JSONDecodeError, ValueError) as error:
        raise HTTPException(400, "Expected a JSON body") from error
    if not isinstance(payload, dict):
        raise HTTPException(422, "Expected a JSON object")
    return payload


def clean_text(payload, key, maximum, required=True):
    value = str(payload.get(key, "")).strip()
    if required and not value:
        raise HTTPException(422, f"{key} is required")
    if len(value) > maximum:
        raise HTTPException(422, f"{key} is too long")
    return value

What do the validation helpers protect?

json_payload() rejects malformed request bodies. clean_text() trims text before enforcing required fields and length limits.

These checks return readable client errors before a database request begins.

  • Add the project and message payload builders:


def project_payload(payload):
    repo_url = clean_text(payload, "repo_url", 300, False)
    if repo_url and not repo_url.startswith("https://github.com/"):
        raise HTTPException(422, "repo_url must be a GitHub HTTPS URL")
    return {"title": clean_text(payload, "title", 80), "summary": clean_text(payload, "summary", 400), "repo_url": repo_url}


def message_payload(payload):
    email = clean_text(payload, "email", 200)
    if "@" not in email:
        raise HTTPException(422, "email must contain @")
    return {"name": clean_text(payload, "name", 80), "email": email, "body": clean_text(payload, "body", 1200)}

How are incoming forms shaped?

Project payloads accept a title, summary, and optional GitHub URL. Message payloads accept a visitor name, email address, and message body.

Each payload contains only fields that the matching database table expects.

  • Add creator verification and the Supabase-backed page routes:


async def require_creator(request):
    authorization = request.headers.get("authorization", "")
    if not authorization.startswith("Bearer "):
        raise HTTPException(401, "Sign in required")
    token = authorization.removeprefix("Bearer ").strip()
    user = await asyncio.to_thread(supabase_request, "/auth/v1/user", "GET", None, token)
    if str(user.get("email", "")).lower() != CREATOR_EMAIL.lower():
        raise HTTPException(403, "Creator account required")
    return token


@app.get("/", response_class=HTMLResponse)
async def home(request: Request):
    projects = await asyncio.to_thread(supabase_request, "/rest/v1/projects?select=id,title,summary,repo_url&order=created_at.desc")
    return templates.TemplateResponse(request=request, name="index.html", context={
        "projects": projects or [], "supabase_url": SUPABASE_URL, "supabase_key": SUPABASE_KEY,
        "creator_email": CREATOR_EMAIL, "resume_url": RESUME_URL,
    })


@app.post("/api/messages")
async def create_message(request: Request):
    await asyncio.to_thread(supabase_request, "/rest/v1/messages", "POST", message_payload(await json_payload(request)))
    return {"ok": True}


@app.get("/api/messages")
async def list_messages(request: Request):
    token = await require_creator(request)
    result = await asyncio.to_thread(supabase_request, "/rest/v1/messages?select=id,name,email,body,reply,created_at&order=created_at.desc", "GET", None, token)
    return result or []

Where does the access barrier live?

require_creator() rejects requests without a bearer token. It asks Supabase to verify any token that is supplied.

The home route now reads project cards from /rest/v1/projects. Inbox reads pass through creator verification first.

  • Finish main.py with the protected project routes and extension hook:


@app.post("/api/projects")
async def create_project(request: Request):
    token = await require_creator(request)
    await asyncio.to_thread(supabase_request, "/rest/v1/projects", "POST", project_payload(await json_payload(request)), token)
    return {"ok": True}


@app.patch("/api/projects/{project_id}")
async def update_project(project_id: int, request: Request):
    token = await require_creator(request)
    await asyncio.to_thread(supabase_request, f"/rest/v1/projects?id=eq.{project_id}", "PATCH", project_payload(await json_payload(request)), token)
    return {"ok": True}


@app.delete("/api/projects/{project_id}")
async def delete_project(project_id: int, request: Request):
    token = await require_creator(request)
    await asyncio.to_thread(supabase_request, f"/rest/v1/projects?id=eq.{project_id}", "DELETE", None, token)
    return {"ok": True}


if Path("secret_reply.py").exists():
    from secret_reply import register_reply_route
    register_reply_route(app, require_creator, json_payload, clean_text, supabase_request)

What do the project routes allow?

The three routes cover create, update, and delete operations. Each route calls require_creator() before sending data to Supabase.

The final hook keeps the optional reply extension separate from the core project.

  • Save main.py.
  • Switch back to the terminal running Uvicorn.
  • Stop the current process with Ctrl+C.
  • Restart the updated application by running:
uvicorn main:app

What does this restart confirm?

Uvicorn imports the updated module from the beginning. A clean startup proves that Python loaded .env and registered every route.

  • Confirm that the terminal reports a successful application startup.
  • Leave Uvicorn running for the browser checks.

Application fails during startup?

An environment-variable error means .env is missing a required name. Confirm that it contains SUPABASE_URL and SUPABASE_KEY.

A connection error usually means the project URL was copied incorrectly. Confirm that the value begins with https://.

help me debug my FastAPI Supabase startup error

Replace the browser layer and test the barrier

The backend now supplies projects and public configuration to the template. The browser layer can render that data while keeping creator controls hidden from signed-out visitors.

  • Open templates/index.html in Cursor.
  • Replace its contents with the Supabase-backed template below:
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="description" content="A full-stack Python developer portfolio">
  <title>Python Developer Portfolio</title>
  <link rel="stylesheet" href="{{ url_for('static', path='/styles.css') }}">
</head>
<body data-supabase-url="{{ supabase_url }}" data-supabase-key="{{ supabase_key }}" data-creator-email="{{ creator_email }}">
<header><a class="brand" href="#top">Python Portfolio</a><nav><a href="#about">About</a><a href="#skills">Skills</a><a href="#projects">Projects</a><a href="#resume">Resume</a><a href="#contact">Contact</a></nav><button id="sign-in">Creator sign in</button><button id="sign-out" class="creator-only secondary">Sign out</button></header>
<main id="top">
  <section class="hero"><p class="eyebrow">Python developer</p><h1>I build useful products with clean APIs and secure data flows.</h1><p>FastAPI renders this portfolio, Supabase stores it, and Google sign-in protects creator tools.</p><a class="button-link" href="#projects">See the work</a></section>
  <section id="about"><p class="eyebrow">About</p><h2>Full-stack thinking, Python first</h2><p>Replace this with your background, strengths, and target role.</p></section>
  <section id="skills"><p class="eyebrow">Skills</p><h2>Tools I use to ship</h2><div class="skills"><span>Python</span><span>FastAPI</span><span>PostgreSQL</span><span>Supabase</span><span>REST APIs</span><span>OAuth</span><span>HTML</span><span>CSS</span><span>JavaScript</span></div></section>
  <section id="projects"><p class="eyebrow">Projects</p><h2>Selected builds</h2><div class="grid">
    {% for project in projects %}<article class="card" data-project-id="{{ project.id }}" data-title="{{ project.title }}" data-summary="{{ project.summary }}" data-repo-url="{{ project.repo_url }}"><h3>{{ project.title }}</h3><p>{{ project.summary }}</p>{% if project.repo_url %}<a href="{{ project.repo_url }}" target="_blank" rel="noreferrer">View repository</a>{% endif %}<div class="creator-only actions"><button class="edit-project secondary">Edit</button><button class="delete-project danger">Delete</button></div></article>{% else %}<p>No projects yet.</p>{% endfor %}
  </div></section>
  <section id="resume"><p class="eyebrow">Resume</p><h2>Experience at a glance</h2><p><a class="button-link" href="{{ resume_url }}" target="_blank" rel="noreferrer">View PDF</a> <a href="{{ resume_url }}?download=resume.pdf">Download resume</a></p><iframe src="{{ resume_url }}" title="Resume PDF"></iframe></section>
  <section id="contact"><p class="eyebrow">Contact</p><h2>Start a conversation</h2><form id="contact-form"><label>Name<input name="name" maxlength="80" required></label><label>Email<input name="email" type="email" maxlength="200" required></label><label>Message<textarea name="body" maxlength="1200" required></textarea></label><button>Send message</button></form></section>
  <section id="creator-panel" class="creator-only panel"><p class="eyebrow">Creator workspace</p><h2>Manage projects</h2><form id="project-form"><label>Title<input name="title" maxlength="80" required></label><label>Summary<textarea name="summary" maxlength="400" required></textarea></label><label>GitHub URL<input name="repo_url" type="url" placeholder="https://github.com/you/project"></label><button>Add project</button></form><h2>Private inbox</h2><div id="inbox" class="inbox"></div></section>
  <p id="status" class="status" role="status" aria-live="polite"></p>
</main>
<footer>Built with FastAPI, Supabase, and Render.</footer>
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2.118.0"></script><script src="{{ url_for('static', path='/app.js') }}"></script><script src="{{ url_for('static', path='/replies.js') }}"></script>
</body></html>

What changes in the template?

The project grid loops through the rows returned by FastAPI. Each card carries its database ID for later edit and delete operations.

The creator workspace stays hidden until the browser confirms the correct signed-in account. The contact form remains public.

  • Save templates/index.html.
  • Open static/styles.css in Cursor.
  • Replace its contents with the complete responsive styles below:
:root{color-scheme:dark;--bg:#0b1020;--panel:#151c31;--text:#eef2ff;--muted:#a8b1ce;--accent:#7dd3fc;--line:#2a3554;--danger:#fb7185;font-family:Inter,system-ui,sans-serif}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--bg);color:var(--text);line-height:1.6}a{color:var(--accent)}button,input,textarea{font:inherit}header{position:sticky;top:0;z-index:2;display:flex;align-items:center;gap:1rem;padding:1rem 5vw;background:#0b1020ee;border-bottom:1px solid var(--line)}.brand{margin-right:auto;color:var(--text);font-weight:800;text-decoration:none}nav,.actions{display:flex;gap:.75rem;flex-wrap:wrap}nav a{color:var(--muted);text-decoration:none}main{width:min(1100px,92vw);margin:auto}section{padding:4rem 0;border-bottom:1px solid var(--line)}.hero{min-height:70vh;display:grid;align-content:center}h1{max-width:850px;font-size:clamp(2.5rem,7vw,5.5rem);line-height:1.02;margin:.3rem 0 1rem}h2{font-size:clamp(1.8rem,4vw,3rem)}.eyebrow{color:var(--accent);letter-spacing:.14em;text-transform:uppercase;font-size:.78rem;font-weight:800}button,.button-link{border:0;border-radius:.7rem;padding:.7rem 1rem;background:var(--accent);color:#07111f;font-weight:800;text-decoration:none;cursor:pointer}.secondary{background:transparent;color:var(--text);border:1px solid var(--line)}.danger{background:transparent;color:var(--danger);border:1px solid var(--danger)}.skills,.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:1rem}.skills span,.card,form,.message-card{padding:1.2rem;border:1px solid var(--line);border-radius:1rem;background:var(--panel)}.card{display:flex;flex-direction:column;gap:.6rem}.card p{color:var(--muted);flex:1}form{display:grid;gap:1rem;max-width:720px}label{display:grid;gap:.35rem;font-weight:700}input,textarea{width:100%;border:1px solid var(--line);border-radius:.6rem;padding:.8rem;background:#0d1426;color:var(--text)}textarea{min-height:8rem}iframe{width:100%;min-height:620px;border:1px solid var(--line);border-radius:1rem;background:white}.creator-only{display:none}body.creator .creator-only{display:flex}body.creator section.creator-only{display:block}.inbox{display:grid;gap:1rem}.message-card{display:grid;gap:.5rem}.message-card p{margin:0;white-space:pre-wrap}.status{position:fixed;right:1rem;bottom:1rem;max-width:420px;padding:.8rem;background:var(--panel);border-radius:.7rem}.status:empty{display:none}footer{padding:2rem;text-align:center;color:var(--muted)}@media(max-width:760px){header{flex-wrap:wrap}nav{order:3;width:100%;overflow:auto}.skills,.grid{grid-template-columns:1fr}iframe{min-height:460px}}

What do these styles cover?

The stylesheet gives project cards, forms, inbox messages, and status updates a consistent dark interface. Creator-only elements remain hidden unless the page receives the creator state.

The mobile media query changes the project and skills grids to one column below the narrow-width breakpoint.

  • Save static/styles.css.
  • Refresh http://127.0.0.1:8000 in your browser.
  • Confirm that the Projects section displays the Secure Portfolio CMS card from Supabase.
  • Narrow the browser window.
  • Confirm that the project grid changes to one column.

Strong progress. Your portfolio is now rendering database content while preserving its responsive layout.

Don't see the starter project?

Confirm that the Secure Portfolio CMS row exists in the Supabase projects table.

Check that SUPABASE_URL points to the same project where you ran schema.sql.

help me trace why my Supabase project card is missing

The final browser script prepares public messages and protected creator requests. It renders inbox text with DOM text properties so visitor content cannot become executable HTML.

  • Open static/app.js in Cursor.
  • Replace its contents with the browser behavior below:
const config=document.body.dataset;
const {createClient}=supabase;
const client=createClient(config.supabaseUrl,config.supabaseKey);
const statusBox=document.querySelector("#status");
const creatorEmail=config.creatorEmail.toLowerCase();
const status=message=>statusBox.textContent=message;
async function session(){const {data,error}=await client.auth.getSession();if(error)throw error;return data.session}
async function authFetch(url,options={}){const current=await session();if(!current)throw new Error("Sign in required");const headers=new Headers(options.headers||{});headers.set("Authorization",`Bearer ${current.access_token}`);if(options.body)headers.set("Content-Type","application/json");const response=await fetch(url,{...options,headers});if(!response.ok)throw new Error(await response.text());return response.json()}
window.portfolioAuthFetch=authFetch;
async function publicJson(url,options={}){const headers=new Headers(options.headers||{});if(options.body)headers.set("Content-Type","application/json");const response=await fetch(url,{...options,headers});if(!response.ok)throw new Error(await response.text());return response.json()}
const formObject=form=>Object.fromEntries(new FormData(form).entries());
function renderMessages(messages){const inbox=document.querySelector("#inbox");inbox.replaceChildren();for(const message of messages){const card=document.createElement("article");card.className="message-card";card.dataset.messageId=message.id;const heading=document.createElement("h3");heading.textContent=`${message.name} (${message.email})`;const body=document.createElement("p");body.textContent=message.body;const date=document.createElement("small");date.textContent=new Date(message.created_at).toLocaleString();const reply=document.createElement("p");reply.className="saved-reply";reply.textContent=message.reply?`Reply: ${message.reply}`:"No reply yet.";card.append(heading,body,date,reply);inbox.append(card)}document.dispatchEvent(new CustomEvent("messages:loaded",{detail:messages}))}
async function loadWorkspace(){const current=await session();const creator=current?.user?.email?.toLowerCase()===creatorEmail;document.body.classList.toggle("creator",creator);document.querySelector("#sign-in").hidden=Boolean(current);if(creator)renderMessages(await authFetch("/api/messages"))}
document.querySelector("#sign-in").onclick=async()=>{status("Opening Google sign-in...");const {error}=await client.auth.signInWithOAuth({provider:"google",options:{redirectTo:window.location.origin}});if(error)status(error.message)};
document.querySelector("#sign-out").onclick=async()=>{const {error}=await client.auth.signOut({scope:"local"});status(error?error.message:"Signed out");document.body.classList.remove("creator");document.querySelector("#sign-in").hidden=false};
document.querySelector("#contact-form").onsubmit=async event=>{event.preventDefault();try{await publicJson("/api/messages",{method:"POST",body:JSON.stringify(formObject(event.currentTarget))});event.currentTarget.reset();status("Message sent")}catch(error){status(error.message)}};
document.querySelector("#project-form").onsubmit=async event=>{event.preventDefault();try{await authFetch("/api/projects",{method:"POST",body:JSON.stringify(formObject(event.currentTarget))});location.reload()}catch(error){status(error.message)}};
document.querySelector("#projects").onclick=async event=>{const card=event.target.closest(".card");if(!card)return;try{if(event.target.matches(".delete-project")&&confirm(`Delete ${card.dataset.title}?`)){await authFetch(`/api/projects/${card.dataset.projectId}`,{method:"DELETE"});location.reload()}if(event.target.matches(".edit-project")){const title=prompt("Project title",card.dataset.title);if(title===null)return;const summary=prompt("Project summary",card.dataset.summary);if(summary===null)return;const repo_url=prompt("GitHub URL",card.dataset.repoUrl);if(repo_url===null)return;await authFetch(`/api/projects/${card.dataset.projectId}`,{method:"PATCH",body:JSON.stringify({title,summary,repo_url})});location.reload()}}catch(error){status(error.message)}};
loadWorkspace().catch(error=>status(error.message));

How does the browser separate public and protected activity?

publicJson() sends visitor messages without requiring a session. authFetch() refuses protected requests when the browser has no current session.

renderMessages() assigns visitor content through textContent. Browser markup supplied by a visitor remains plain text.

  • Save static/app.js.
  • Refresh the local portfolio.
  • Confirm that the creator workspace remains hidden while you are signed out.

Before you test the protected route, do you expect FastAPI to accept a project write without an identity?

  • Open http://127.0.0.1:8000/docs in a new browser tab.
  • Expand POST /api/projects.
  • Click Try it out.
  • Click Execute.

You will see status 401 with Sign in required. This rejection is intentional because the request contains no verified creator identity.

Why the failed write matters

The backend rejects the request before it reads project data from the body. A public browser cannot reach a protected Supabase write through this route.

The next step supplies a verifiable identity. RLS remains the final database boundary even after the browser signs in.

✔️ Awesome, I've got everything!

Your Supabase schema, FastAPI routes, and browser files are connected. Keep Uvicorn running for the authentication step.

ⓧ I'd like to double check the full code

Compare each changed file with the complete versions below. Keep your own Google address in place of your-email@example.com inside schema.sql and main.py.

create table if not exists public.projects (
  id bigint generated by default as identity primary key,
  title text not null check (char_length(title) between 1 and 80),
  summary text not null check (char_length(summary) between 1 and 400),
  repo_url text not null default '',
  created_at timestamptz not null default now()
);
create table if not exists public.messages (
  id bigint generated by default as identity primary key,
  name text not null check (char_length(name) between 1 and 80),
  email text not null check (char_length(email) between 3 and 200),
  body text not null check (char_length(body) between 1 and 1200),
  reply text,
  created_at timestamptz not null default now()
);
alter table public.projects enable row level security;
alter table public.messages enable row level security;
revoke all on table public.projects, public.messages from anon, authenticated;
grant select on table public.projects to anon, authenticated;
grant insert, update, delete on table public.projects to authenticated;
grant insert on table public.messages to anon;
grant select, update on table public.messages to authenticated;
grant usage, select on sequence public.projects_id_seq to authenticated;
grant usage, select on sequence public.messages_id_seq to anon;

drop policy if exists "Public reads projects" on public.projects;
create policy "Public reads projects" on public.projects for select to anon, authenticated using (true);
drop policy if exists "Creator inserts projects" on public.projects;
create policy "Creator inserts projects" on public.projects for insert to authenticated
with check ((select auth.jwt() ->> 'email') = 'your-email@example.com');
drop policy if exists "Creator updates projects" on public.projects;
create policy "Creator updates projects" on public.projects for update to authenticated
using ((select auth.jwt() ->> 'email') = 'your-email@example.com')
with check ((select auth.jwt() ->> 'email') = 'your-email@example.com');
drop policy if exists "Creator deletes projects" on public.projects;
create policy "Creator deletes projects" on public.projects for delete to authenticated
using ((select auth.jwt() ->> 'email') = 'your-email@example.com');
drop policy if exists "Visitors send messages" on public.messages;
create policy "Visitors send messages" on public.messages for insert to anon with check (true);
drop policy if exists "Creator reads messages" on public.messages;
create policy "Creator reads messages" on public.messages for select to authenticated
using ((select auth.jwt() ->> 'email') = 'your-email@example.com');
drop policy if exists "Creator updates messages" on public.messages;
create policy "Creator updates messages" on public.messages for update to authenticated
using ((select auth.jwt() ->> 'email') = 'your-email@example.com')
with check ((select auth.jwt() ->> 'email') = 'your-email@example.com');

insert into public.projects (title, summary, repo_url)
select 'Secure Portfolio CMS', 'FastAPI, Google sign-in, Supabase RLS, messaging, and deployment.',
'https://github.com/your-github-username/fastapi-portfolio'
where not exists (select 1 from public.projects);

What should match in the schema?

Your file should contain both tables, all grants, all seven policies, and the starter project insert. Your creator email should replace every email placeholder before execution.

import asyncio
import json
import os
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.request import Request as URLRequest, urlopen

from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates

load_dotenv()
SUPABASE_URL = os.environ["SUPABASE_URL"].rstrip("/")
SUPABASE_KEY = os.environ["SUPABASE_KEY"]
CREATOR_EMAIL = "your-email@example.com"
RESUME_URL = f"{SUPABASE_URL}/storage/v1/object/public/resumes/resume.pdf"

app = FastAPI(title="Portfolio CMS")
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")


def supabase_request(path, method="GET", payload=None, token=None):
    data = None if payload is None else json.dumps(payload).encode()
    headers = {"apikey": SUPABASE_KEY, "Authorization": f"Bearer {token or SUPABASE_KEY}", "Accept": "application/json"}
    if data is not None:
        headers["Content-Type"] = "application/json"
    request = URLRequest(f"{SUPABASE_URL}{path}", data=data, headers=headers, method=method)
    try:
        with urlopen(request, timeout=12) as response:
            body = response.read().decode()
            return json.loads(body) if body else None
    except HTTPError as error:
        detail = error.read().decode() or error.reason
        raise HTTPException(error.code, detail) from error
    except URLError as error:
        raise HTTPException(502, "Supabase is unavailable") from error


async def json_payload(request):
    try:
        payload = await request.json()
    except (json.JSONDecodeError, ValueError) as error:
        raise HTTPException(400, "Expected a JSON body") from error
    if not isinstance(payload, dict):
        raise HTTPException(422, "Expected a JSON object")
    return payload


def clean_text(payload, key, maximum, required=True):
    value = str(payload.get(key, "")).strip()
    if required and not value:
        raise HTTPException(422, f"{key} is required")
    if len(value) > maximum:
        raise HTTPException(422, f"{key} is too long")
    return value


def project_payload(payload):
    repo_url = clean_text(payload, "repo_url", 300, False)
    if repo_url and not repo_url.startswith("https://github.com/"):
        raise HTTPException(422, "repo_url must be a GitHub HTTPS URL")
    return {"title": clean_text(payload, "title", 80), "summary": clean_text(payload, "summary", 400), "repo_url": repo_url}


def message_payload(payload):
    email = clean_text(payload, "email", 200)
    if "@" not in email:
        raise HTTPException(422, "email must contain @")
    return {"name": clean_text(payload, "name", 80), "email": email, "body": clean_text(payload, "body", 1200)}


async def require_creator(request):
    authorization = request.headers.get("authorization", "")
    if not authorization.startswith("Bearer "):
        raise HTTPException(401, "Sign in required")
    token = authorization.removeprefix("Bearer ").strip()
    user = await asyncio.to_thread(supabase_request, "/auth/v1/user", "GET", None, token)
    if str(user.get("email", "")).lower() != CREATOR_EMAIL.lower():
        raise HTTPException(403, "Creator account required")
    return token


@app.get("/", response_class=HTMLResponse)
async def home(request: Request):
    projects = await asyncio.to_thread(supabase_request, "/rest/v1/projects?select=id,title,summary,repo_url&order=created_at.desc")
    return templates.TemplateResponse(request=request, name="index.html", context={
        "projects": projects or [], "supabase_url": SUPABASE_URL, "supabase_key": SUPABASE_KEY,
        "creator_email": CREATOR_EMAIL, "resume_url": RESUME_URL,
    })


@app.post("/api/messages")
async def create_message(request: Request):
    await asyncio.to_thread(supabase_request, "/rest/v1/messages", "POST", message_payload(await json_payload(request)))
    return {"ok": True}


@app.get("/api/messages")
async def list_messages(request: Request):
    token = await require_creator(request)
    result = await asyncio.to_thread(supabase_request, "/rest/v1/messages?select=id,name,email,body,reply,created_at&order=created_at.desc", "GET", None, token)
    return result or []


@app.post("/api/projects")
async def create_project(request: Request):
    token = await require_creator(request)
    await asyncio.to_thread(supabase_request, "/rest/v1/projects", "POST", project_payload(await json_payload(request)), token)
    return {"ok": True}


@app.patch("/api/projects/{project_id}")
async def update_project(project_id: int, request: Request):
    token = await require_creator(request)
    await asyncio.to_thread(supabase_request, f"/rest/v1/projects?id=eq.{project_id}", "PATCH", project_payload(await json_payload(request)), token)
    return {"ok": True}


@app.delete("/api/projects/{project_id}")
async def delete_project(project_id: int, request: Request):
    token = await require_creator(request)
    await asyncio.to_thread(supabase_request, f"/rest/v1/projects?id=eq.{project_id}", "DELETE", None, token)
    return {"ok": True}


if Path("secret_reply.py").exists():
    from secret_reply import register_reply_route
    register_reply_route(app, require_creator, json_payload, clean_text, supabase_request)

What should match in the backend?

Your backend should load environment values, validate incoming data, verify creator tokens, read public projects, and expose the protected project routes.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="description" content="A full-stack Python developer portfolio">
  <title>Python Developer Portfolio</title>
  <link rel="stylesheet" href="{{ url_for('static', path='/styles.css') }}">
</head>
<body data-supabase-url="{{ supabase_url }}" data-supabase-key="{{ supabase_key }}" data-creator-email="{{ creator_email }}">
<header><a class="brand" href="#top">Python Portfolio</a><nav><a href="#about">About</a><a href="#skills">Skills</a><a href="#projects">Projects</a><a href="#resume">Resume</a><a href="#contact">Contact</a></nav><button id="sign-in">Creator sign in</button><button id="sign-out" class="creator-only secondary">Sign out</button></header>
<main id="top">
  <section class="hero"><p class="eyebrow">Python developer</p><h1>I build useful products with clean APIs and secure data flows.</h1><p>FastAPI renders this portfolio, Supabase stores it, and Google sign-in protects creator tools.</p><a class="button-link" href="#projects">See the work</a></section>
  <section id="about"><p class="eyebrow">About</p><h2>Full-stack thinking, Python first</h2><p>Replace this with your background, strengths, and target role.</p></section>
  <section id="skills"><p class="eyebrow">Skills</p><h2>Tools I use to ship</h2><div class="skills"><span>Python</span><span>FastAPI</span><span>PostgreSQL</span><span>Supabase</span><span>REST APIs</span><span>OAuth</span><span>HTML</span><span>CSS</span><span>JavaScript</span></div></section>
  <section id="projects"><p class="eyebrow">Projects</p><h2>Selected builds</h2><div class="grid">
    {% for project in projects %}<article class="card" data-project-id="{{ project.id }}" data-title="{{ project.title }}" data-summary="{{ project.summary }}" data-repo-url="{{ project.repo_url }}"><h3>{{ project.title }}</h3><p>{{ project.summary }}</p>{% if project.repo_url %}<a href="{{ project.repo_url }}" target="_blank" rel="noreferrer">View repository</a>{% endif %}<div class="creator-only actions"><button class="edit-project secondary">Edit</button><button class="delete-project danger">Delete</button></div></article>{% else %}<p>No projects yet.</p>{% endfor %}
  </div></section>
  <section id="resume"><p class="eyebrow">Resume</p><h2>Experience at a glance</h2><p><a class="button-link" href="{{ resume_url }}" target="_blank" rel="noreferrer">View PDF</a> <a href="{{ resume_url }}?download=resume.pdf">Download resume</a></p><iframe src="{{ resume_url }}" title="Resume PDF"></iframe></section>
  <section id="contact"><p class="eyebrow">Contact</p><h2>Start a conversation</h2><form id="contact-form"><label>Name<input name="name" maxlength="80" required></label><label>Email<input name="email" type="email" maxlength="200" required></label><label>Message<textarea name="body" maxlength="1200" required></textarea></label><button>Send message</button></form></section>
  <section id="creator-panel" class="creator-only panel"><p class="eyebrow">Creator workspace</p><h2>Manage projects</h2><form id="project-form"><label>Title<input name="title" maxlength="80" required></label><label>Summary<textarea name="summary" maxlength="400" required></textarea></label><label>GitHub URL<input name="repo_url" type="url" placeholder="https://github.com/you/project"></label><button>Add project</button></form><h2>Private inbox</h2><div id="inbox" class="inbox"></div></section>
  <p id="status" class="status" role="status" aria-live="polite"></p>
</main>
<footer>Built with FastAPI, Supabase, and Render.</footer>
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2.118.0"></script><script src="{{ url_for('static', path='/app.js') }}"></script><script src="{{ url_for('static', path='/replies.js') }}"></script>
</body></html>

What should match in the template?

Your template should loop through Supabase projects. It should also contain the public contact form, hidden creator workspace, status area, and three script tags.

:root{color-scheme:dark;--bg:#0b1020;--panel:#151c31;--text:#eef2ff;--muted:#a8b1ce;--accent:#7dd3fc;--line:#2a3554;--danger:#fb7185;font-family:Inter,system-ui,sans-serif}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--bg);color:var(--text);line-height:1.6}a{color:var(--accent)}button,input,textarea{font:inherit}header{position:sticky;top:0;z-index:2;display:flex;align-items:center;gap:1rem;padding:1rem 5vw;background:#0b1020ee;border-bottom:1px solid var(--line)}.brand{margin-right:auto;color:var(--text);font-weight:800;text-decoration:none}nav,.actions{display:flex;gap:.75rem;flex-wrap:wrap}nav a{color:var(--muted);text-decoration:none}main{width:min(1100px,92vw);margin:auto}section{padding:4rem 0;border-bottom:1px solid var(--line)}.hero{min-height:70vh;display:grid;align-content:center}h1{max-width:850px;font-size:clamp(2.5rem,7vw,5.5rem);line-height:1.02;margin:.3rem 0 1rem}h2{font-size:clamp(1.8rem,4vw,3rem)}.eyebrow{color:var(--accent);letter-spacing:.14em;text-transform:uppercase;font-size:.78rem;font-weight:800}button,.button-link{border:0;border-radius:.7rem;padding:.7rem 1rem;background:var(--accent);color:#07111f;font-weight:800;text-decoration:none;cursor:pointer}.secondary{background:transparent;color:var(--text);border:1px solid var(--line)}.danger{background:transparent;color:var(--danger);border:1px solid var(--danger)}.skills,.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:1rem}.skills span,.card,form,.message-card{padding:1.2rem;border:1px solid var(--line);border-radius:1rem;background:var(--panel)}.card{display:flex;flex-direction:column;gap:.6rem}.card p{color:var(--muted);flex:1}form{display:grid;gap:1rem;max-width:720px}label{display:grid;gap:.35rem;font-weight:700}input,textarea{width:100%;border:1px solid var(--line);border-radius:.6rem;padding:.8rem;background:#0d1426;color:var(--text)}textarea{min-height:8rem}iframe{width:100%;min-height:620px;border:1px solid var(--line);border-radius:1rem;background:white}.creator-only{display:none}body.creator .creator-only{display:flex}body.creator section.creator-only{display:block}.inbox{display:grid;gap:1rem}.message-card{display:grid;gap:.5rem}.message-card p{margin:0;white-space:pre-wrap}.status{position:fixed;right:1rem;bottom:1rem;max-width:420px;padding:.8rem;background:var(--panel);border-radius:.7rem}.status:empty{display:none}footer{padding:2rem;text-align:center;color:var(--muted)}@media(max-width:760px){header{flex-wrap:wrap}nav{order:3;width:100%;overflow:auto}.skills,.grid{grid-template-columns:1fr}iframe{min-height:460px}}

What should match in the stylesheet?

Your stylesheet should cover the public sections, creator controls, forms, project cards, inbox cards, status message, and mobile layout.

const config=document.body.dataset;
const {createClient}=supabase;
const client=createClient(config.supabaseUrl,config.supabaseKey);
const statusBox=document.querySelector("#status");
const creatorEmail=config.creatorEmail.toLowerCase();
const status=message=>statusBox.textContent=message;
async function session(){const {data,error}=await client.auth.getSession();if(error)throw error;return data.session}
async function authFetch(url,options={}){const current=await session();if(!current)throw new Error("Sign in required");const headers=new Headers(options.headers||{});headers.set("Authorization",`Bearer ${current.access_token}`);if(options.body)headers.set("Content-Type","application/json");const response=await fetch(url,{...options,headers});if(!response.ok)throw new Error(await response.text());return response.json()}
window.portfolioAuthFetch=authFetch;
async function publicJson(url,options={}){const headers=new Headers(options.headers||{});if(options.body)headers.set("Content-Type","application/json");const response=await fetch(url,{...options,headers});if(!response.ok)throw new Error(await response.text());return response.json()}
const formObject=form=>Object.fromEntries(new FormData(form).entries());
function renderMessages(messages){const inbox=document.querySelector("#inbox");inbox.replaceChildren();for(const message of messages){const card=document.createElement("article");card.className="message-card";card.dataset.messageId=message.id;const heading=document.createElement("h3");heading.textContent=`${message.name} (${message.email})`;const body=document.createElement("p");body.textContent=message.body;const date=document.createElement("small");date.textContent=new Date(message.created_at).toLocaleString();const reply=document.createElement("p");reply.className="saved-reply";reply.textContent=message.reply?`Reply: ${message.reply}`:"No reply yet.";card.append(heading,body,date,reply);inbox.append(card)}document.dispatchEvent(new CustomEvent("messages:loaded",{detail:messages}))}
async function loadWorkspace(){const current=await session();const creator=current?.user?.email?.toLowerCase()===creatorEmail;document.body.classList.toggle("creator",creator);document.querySelector("#sign-in").hidden=Boolean(current);if(creator)renderMessages(await authFetch("/api/messages"))}
document.querySelector("#sign-in").onclick=async()=>{status("Opening Google sign-in...");const {error}=await client.auth.signInWithOAuth({provider:"google",options:{redirectTo:window.location.origin}});if(error)status(error.message)};
document.querySelector("#sign-out").onclick=async()=>{const {error}=await client.auth.signOut({scope:"local"});status(error?error.message:"Signed out");document.body.classList.remove("creator");document.querySelector("#sign-in").hidden=false};
document.querySelector("#contact-form").onsubmit=async event=>{event.preventDefault();try{await publicJson("/api/messages",{method:"POST",body:JSON.stringify(formObject(event.currentTarget))});event.currentTarget.reset();status("Message sent")}catch(error){status(error.message)}};
document.querySelector("#project-form").onsubmit=async event=>{event.preventDefault();try{await authFetch("/api/projects",{method:"POST",body:JSON.stringify(formObject(event.currentTarget))});location.reload()}catch(error){status(error.message)}};
document.querySelector("#projects").onclick=async event=>{const card=event.target.closest(".card");if(!card)return;try{if(event.target.matches(".delete-project")&&confirm(`Delete ${card.dataset.title}?`)){await authFetch(`/api/projects/${card.dataset.projectId}`,{method:"DELETE"});location.reload()}if(event.target.matches(".edit-project")){const title=prompt("Project title",card.dataset.title);if(title===null)return;const summary=prompt("Project summary",card.dataset.summary);if(summary===null)return;const repo_url=prompt("GitHub URL",card.dataset.repoUrl);if(repo_url===null)return;await authFetch(`/api/projects/${card.dataset.projectId}`,{method:"PATCH",body:JSON.stringify({title,summary,repo_url})});location.reload()}}catch(error){status(error.message)}};
loadWorkspace().catch(error=>status(error.message));

What should match in the browser script?

Your script should prepare the Supabase client, public message requests, protected requests, safe inbox rendering, project controls, and signed-out status handling.

Your public portfolio now reads project cards from Supabase. Next, you will give the protected routes a verified Google identity so creator CRUD can pass both backend validation and RLS.

Enable Google Creator CRUD

Your Supabase-backed portfolio already reads live project cards. The rejected write proved that protected changes need a verified identity.

Google OAuth now supplies that identity. FastAPI checks the signed-in email before Supabase applies its database policies.

In this step, get ready to:
  • Configure a Google Auth Platform Web OAuth client for the local portfolio.
  • Connect the browser session to the protected FastAPI endpoints.
  • Test creator CRUD with an approved account and a different Google account.
Configure Google OAuth

Google needs to know which browser origin can start sign-in. It also needs the Supabase callback URL that receives the result.

  • Return to the Supabase dashboard from the previous step.
  • Open the authentication provider area for your project.
  • Select the Google provider.
  • Record the callback URL shown on the provider page here: your Supabase callback URL.
  • Open Google Auth Platform in a new browser tab.
  • Select the Google Cloud project that holds this portfolio client.
  • Open Clients.
  • Click CREATE CLIENT.

How does the redirect work?

Google authenticates the selected account before sending the result to the Supabase callback URL. Supabase creates the browser session that your portfolio reads.

  • Choose Web application as the application type.
  • Enter FastAPI Portfolio Local in the name field.
  • Enter http://127.0.0.1:8000 under Authorized JavaScript origins.
  • Enter your Supabase callback URL under Authorized redirect URIs.
  • Click CREATE CLIENT.

The client secret is sensitive. You paste it directly into Supabase without storing it in your project files.

  • Record the displayed client ID here: your Google OAuth client ID.
  • Copy the displayed client secret to your clipboard.
  • Return to the Supabase Google provider page.
  • Paste your Google OAuth client ID into the client ID field.
  • Paste the client secret from your clipboard into the client secret field.
  • Enable the Google provider.
  • Keep only the openid, email, and profile scopes.
  • Save the provider configuration.

Where is access enforced?

Google identifies the account. FastAPI verifies the session token through /auth/v1/user.

Postgres Row Level Security checks the verified email again during each write. These checks keep the database authoritative.

  • Open the redirect allow list for your Supabase project.
  • Add http://127.0.0.1:8000 as an allowed redirect URL.
  • Save the redirect configuration.

Your local origin can now start Google sign-in. Supabase can now return the authenticated session to the same origin.

Connect the browser session

The template already loads Supabase JS 2.118.0. The browser script now uses that client to start sign-in and retrieve the active session.

The session access token is a JWT. The browser sends it through the Authorization header whenever it calls a protected endpoint.

  • Switch back to static/app.js in Cursor.
  • Replace its contents with the complete browser client below:
const config=document.body.dataset;
const {createClient}=supabase;
const client=createClient(config.supabaseUrl,config.supabaseKey);
const statusBox=document.querySelector("#status");
const creatorEmail=config.creatorEmail.toLowerCase();
const status=message=>statusBox.textContent=message;
async function session(){const {data,error}=await client.auth.getSession();if(error)throw error;return data.session}
async function authFetch(url,options={}){const current=await session();if(!current)throw new Error("Sign in required");const headers=new Headers(options.headers||{});headers.set("Authorization",`Bearer ${current.access_token}`);if(options.body)headers.set("Content-Type","application/json");const response=await fetch(url,{...options,headers});if(!response.ok)throw new Error(await response.text());return response.json()}
window.portfolioAuthFetch=authFetch;
async function publicJson(url,options={}){const headers=new Headers(options.headers||{});if(options.body)headers.set("Content-Type","application/json");const response=await fetch(url,{...options,headers});if(!response.ok)throw new Error(await response.text());return response.json()}
const formObject=form=>Object.fromEntries(new FormData(form).entries());
function renderMessages(messages){const inbox=document.querySelector("#inbox");inbox.replaceChildren();for(const message of messages){const card=document.createElement("article");card.className="message-card";card.dataset.messageId=message.id;const heading=document.createElement("h3");heading.textContent=`${message.name} (${message.email})`;const body=document.createElement("p");body.textContent=message.body;const date=document.createElement("small");date.textContent=new Date(message.created_at).toLocaleString();const reply=document.createElement("p");reply.className="saved-reply";reply.textContent=message.reply?`Reply: ${message.reply}`:"No reply yet.";card.append(heading,body,date,reply);inbox.append(card)}document.dispatchEvent(new CustomEvent("messages:loaded",{detail:messages}))}
async function loadWorkspace(){const current=await session();const creator=current?.user?.email?.toLowerCase()===creatorEmail;document.body.classList.toggle("creator",creator);document.querySelector("#sign-in").hidden=Boolean(current);if(creator)renderMessages(await authFetch("/api/messages"))}
document.querySelector("#sign-in").onclick=async()=>{status("Opening Google sign-in...");const {error}=await client.auth.signInWithOAuth({provider:"google",options:{redirectTo:window.location.origin}});if(error)status(error.message)};
document.querySelector("#sign-out").onclick=async()=>{const {error}=await client.auth.signOut({scope:"local"});status(error?error.message:"Signed out");document.body.classList.remove("creator");document.querySelector("#sign-in").hidden=false};
document.querySelector("#contact-form").onsubmit=async event=>{event.preventDefault();try{await publicJson("/api/messages",{method:"POST",body:JSON.stringify(formObject(event.currentTarget))});event.currentTarget.reset();status("Message sent")}catch(error){status(error.message)}};
document.querySelector("#project-form").onsubmit=async event=>{event.preventDefault();try{await authFetch("/api/projects",{method:"POST",body:JSON.stringify(formObject(event.currentTarget))});location.reload()}catch(error){status(error.message)}};
document.querySelector("#projects").onclick=async event=>{const card=event.target.closest(".card");if(!card)return;try{if(event.target.matches(".delete-project")&&confirm(`Delete ${card.dataset.title}?`)){await authFetch(`/api/projects/${card.dataset.projectId}`,{method:"DELETE"});location.reload()}if(event.target.matches(".edit-project")){const title=prompt("Project title",card.dataset.title);if(title===null)return;const summary=prompt("Project summary",card.dataset.summary);if(summary===null)return;const repo_url=prompt("GitHub URL",card.dataset.repoUrl);if(repo_url===null)return;await authFetch(`/api/projects/${card.dataset.projectId}`,{method:"PATCH",body:JSON.stringify({title,summary,repo_url})});location.reload()}}catch(error){status(error.message)}};
loadWorkspace().catch(error=>status(error.message));

What does this code do?

  • The Supabase client reads its URL and publishable key from the page data attributes.
  • The session() helper retrieves the current browser session.
  • The authFetch() helper adds the session access token to protected requests.
  • The loadWorkspace() function reveals creator controls only when the signed-in email matches CREATOR_EMAIL.
  • The project handlers create, edit, and delete cards through the protected FastAPI routes.
  • The inbox renderer uses textContent so visitor messages are displayed as text.
  • Save static/app.js.

✔️ Awesome, I've got everything!

Good work. Your browser client now carries authenticated sessions into the protected FastAPI routes.

ⓧ I'd like to double check the full code

Compare your saved static/app.js with this complete file:

const config=document.body.dataset;
const {createClient}=supabase;
const client=createClient(config.supabaseUrl,config.supabaseKey);
const statusBox=document.querySelector("#status");
const creatorEmail=config.creatorEmail.toLowerCase();
const status=message=>statusBox.textContent=message;
async function session(){const {data,error}=await client.auth.getSession();if(error)throw error;return data.session}
async function authFetch(url,options={}){const current=await session();if(!current)throw new Error("Sign in required");const headers=new Headers(options.headers||{});headers.set("Authorization",`Bearer ${current.access_token}`);if(options.body)headers.set("Content-Type","application/json");const response=await fetch(url,{...options,headers});if(!response.ok)throw new Error(await response.text());return response.json()}
window.portfolioAuthFetch=authFetch;
async function publicJson(url,options={}){const headers=new Headers(options.headers||{});if(options.body)headers.set("Content-Type","application/json");const response=await fetch(url,{...options,headers});if(!response.ok)throw new Error(await response.text());return response.json()}
const formObject=form=>Object.fromEntries(new FormData(form).entries());
function renderMessages(messages){const inbox=document.querySelector("#inbox");inbox.replaceChildren();for(const message of messages){const card=document.createElement("article");card.className="message-card";card.dataset.messageId=message.id;const heading=document.createElement("h3");heading.textContent=`${message.name} (${message.email})`;const body=document.createElement("p");body.textContent=message.body;const date=document.createElement("small");date.textContent=new Date(message.created_at).toLocaleString();const reply=document.createElement("p");reply.className="saved-reply";reply.textContent=message.reply?`Reply: ${message.reply}`:"No reply yet.";card.append(heading,body,date,reply);inbox.append(card)}document.dispatchEvent(new CustomEvent("messages:loaded",{detail:messages}))}
async function loadWorkspace(){const current=await session();const creator=current?.user?.email?.toLowerCase()===creatorEmail;document.body.classList.toggle("creator",creator);document.querySelector("#sign-in").hidden=Boolean(current);if(creator)renderMessages(await authFetch("/api/messages"))}
document.querySelector("#sign-in").onclick=async()=>{status("Opening Google sign-in...");const {error}=await client.auth.signInWithOAuth({provider:"google",options:{redirectTo:window.location.origin}});if(error)status(error.message)};
document.querySelector("#sign-out").onclick=async()=>{const {error}=await client.auth.signOut({scope:"local"});status(error?error.message:"Signed out");document.body.classList.remove("creator");document.querySelector("#sign-in").hidden=false};
document.querySelector("#contact-form").onsubmit=async event=>{event.preventDefault();try{await publicJson("/api/messages",{method:"POST",body:JSON.stringify(formObject(event.currentTarget))});event.currentTarget.reset();status("Message sent")}catch(error){status(error.message)}};
document.querySelector("#project-form").onsubmit=async event=>{event.preventDefault();try{await authFetch("/api/projects",{method:"POST",body:JSON.stringify(formObject(event.currentTarget))});location.reload()}catch(error){status(error.message)}};
document.querySelector("#projects").onclick=async event=>{const card=event.target.closest(".card");if(!card)return;try{if(event.target.matches(".delete-project")&&confirm(`Delete ${card.dataset.title}?`)){await authFetch(`/api/projects/${card.dataset.projectId}`,{method:"DELETE"});location.reload()}if(event.target.matches(".edit-project")){const title=prompt("Project title",card.dataset.title);if(title===null)return;const summary=prompt("Project summary",card.dataset.summary);if(summary===null)return;const repo_url=prompt("GitHub URL",card.dataset.repoUrl);if(repo_url===null)return;await authFetch(`/api/projects/${card.dataset.projectId}`,{method:"PATCH",body:JSON.stringify({title,summary,repo_url})});location.reload()}}catch(error){status(error.message)}};
loadWorkspace().catch(error=>status(error.message));

The local server stays attached to the Cursor terminal while your portfolio is running. Keep that terminal available during the browser checks.

  • Start the local FastAPI application by running this command:
uvicorn main:app

What does this command do?

Uvicorn loads the app object from main.py. It serves the portfolio at http://127.0.0.1:8000.

  • Open http://127.0.0.1:8000 in your browser.
  • Click Creator sign in in the portfolio header.
  • Select the Google account whose address matches CREATOR_EMAIL.

Fresh Google OAuth settings can take from five minutes to a few hours to propagate. A short delay after configuration does not mean your code is broken.

After Google redirects you back, you should see the creator project form and private inbox. That visible workspace confirms the email check passed.

Sign-in did not reveal the workspace?

If Google reports redirect_uri_mismatch, compare the Google redirect URI with your Supabase callback URL.

If sign-in succeeds without revealing the controls, confirm that CREATOR_EMAIL matches the selected Google address.

Ask for help with the complete authentication path: help me debug why Google sign-in does not reveal my creator workspace

Prove creator-only CRUD

The creator controls now exercise the protected project routes. Each successful change proves that the browser token passed FastAPI verification and Supabase policies.

  • Enter OAuth CRUD Check in the Title field.
  • Enter Created through protected FastAPI endpoints. in the Summary field.
  • Enter https://github.com/your-github-username/fastapi-portfolio in the GitHub URL field.
  • Click Add project.

That is the authenticated create path working. The page reloads with an OAuth CRUD Check card in the projects grid.

What did the create prove?

The browser sent the session token to FastAPI. The backend verified the creator email before forwarding the token to Supabase.

The database accepted the insert because its creator policy found the same email claim. Both layers agreed on the identity.

  • Click Edit on the OAuth CRUD Check card.
  • Replace the title with OAuth CRUD Check Updated in the Project title prompt.
  • Confirm the title prompt.
  • Replace the summary with Creator update accepted by FastAPI and RLS. in the Project summary prompt.
  • Confirm the summary prompt.
  • Keep https://github.com/your-github-username/fastapi-portfolio in the GitHub URL prompt.
  • Confirm the URL prompt.

You should see the updated title and summary after the page reloads. The edit confirms the protected update route works.

  • Click Delete on the updated test card.
  • Approve the deletion in the confirmation dialog.

The test card disappears after the reload. You have now completed creator-authenticated create, update, and delete operations.

Hidden controls improve the interface, but the server must still reject a user who reveals them manually. This last check proves the protection survives a browser-side bypass.

  • Click Sign out.
  • Click Creator sign in.
  • Select a different Google account.
  • Confirm that the creator controls stay hidden after the redirect.
  • Press F12 to open the browser developer tools.
  • Select the body element in the DOM tree.
  • Add creator to the element's class attribute.

The creator form becomes visible because you changed the page styling. Your Google account still lacks creator permission on the server.

  • Enter Blocked Account Check in the Title field.
  • Enter This write should be rejected. in the Summary field.
  • Enter https://github.com/your-github-username/fastapi-portfolio in the GitHub URL field.

Before you submit, do you think revealing the form changed the account's server-side permission?

  • Click Add project.

The status message includes Creator account required. The card is not created because require_creator() rejects the email before the write reaches the database.

Why the browser bypass failed

The creator class only changes what the browser displays. It grants no permission.

FastAPI validates the token through /auth/v1/user before comparing the verified email. Supabase RLS provides another creator-email check for protected database writes.

  • Click Sign out to finish the alternate-account test.

Your portfolio now supports Google sign-in and creator-only project management. Next, you will add the public resume, visitor inbox, and live deployment.

Publish the Resume, Inbox, and App

Your creator account can now manage projects through protected FastAPI endpoints. Supabase enforces the same creator boundary at the database layer.

The portfolio still lives on your computer. Recruiters cannot view your resume or send you a message from their own devices.

Publishing closes that gap. You will store the resume in Supabase Storage. You will verify the inbox before deploying the app through GitHub and Render.

In this step, get ready to:
  • Publish your PDF resume from a public Supabase Storage bucket.
  • Verify public message submission and private inbox rendering.
  • Deploy the portfolio from GitHub to a public Render URL.
Publish the resume and verify messaging

Supabase Storage keeps files outside the application server. A public bucket gives your portfolio a stable URL for viewing or downloading the resume.

Why is the resume public?

Making a storage bucket public can feel exposed. This bucket contains only the resume you intend to share through your portfolio.

Anyone with the object URL can retrieve a file from a public bucket. Keep private documents outside resumes.

  • Return to the Supabase dashboard from earlier.
  • Select Storage from the project navigation.
  • Click New Bucket.
  • Enter resumes as the bucket name.
  • Enable the option that makes the bucket public.
  • Click Create Bucket.

You will see resumes in the Storage page. The bucket is ready to serve a public portfolio asset.

  • In Windows File Explorer, rename your PDF to resume.pdf.
  • Select the resumes bucket in Supabase.
  • Click Upload File.
  • Select resume.pdf from your computer.
  • Confirm the file selection to begin the upload.

The bucket now lists resume.pdf. Your existing RESUME_URL points to this exact object.

How does the resume URL work?

A public Supabase Storage URL includes the project address. It also includes the bucket name and object name.

Your main.py file combines those parts into RESUME_URL. The Jinja template uses that value for the viewer and download links.

  • Return to the fastapi-portfolio workspace in Cursor.
  • Open a terminal for the workspace.
  • Start the local portfolio server by running this command:
uvicorn main:app

What does this command do?

Uvicorn imports app from main.py. It then serves your FastAPI application locally.

  • Open http://127.0.0.1:8000 in your browser.
  • Scroll to the Resume section.
  • Click View PDF.

You will see resume.pdf open in the browser. The embedded viewer and download link now use the public object.

Does the PDF fail to open?

Check that the bucket is named exactly resumes. Confirm the uploaded object is named exactly resume.pdf.

Confirm that the bucket allows public retrieval. A private bucket rejects the public URL used by RESUME_URL.

If the server stopped during testing, help me diagnose the local FastAPI server.

  • Return to the portfolio tab.
  • Scroll to the Contact section.
  • Enter Portfolio test in the Name field.
  • Enter portfolio-test@example.com in the Email field.
  • Enter Local inbox verification. in the Message field.
  • Click Send message.

You will see Message sent in the status box. The public POST /api/messages endpoint has stored the test message.

  • Click Creator sign in.
  • Choose the Google account that matches CREATOR_EMAIL.
  • Scroll to the Private inbox.

You will see the test sender and message inside a message card. The creator-only GET /api/messages endpoint supplied the inbox data.

Why use textContent for messages?

Visitor messages are untrusted input. renderMessages() assigns each value through textContent.

The browser displays submitted text without interpreting it as HTML. This keeps message content from becoming executable page markup.

Push the source to GitHub

Render deploys from a remote repository. Your local project needs one committed snapshot before the hosting service can build it.

Keep local configuration private

Pushing a project can feel risky when local configuration is involved. Your existing .gitignore excludes .env from the repository.

Only .env.example belongs in GitHub. It documents the required variable names without exposing your populated values.

  • Stop the local server by pressing Ctrl+C in the Cursor terminal.
  • Initialize the repository and inspect the staged files by running these commands:
git init -b main
git add .
git status

What do these commands do?

  • The first command creates a Git repository with main as its initial branch.
  • The second command stages project files that are not excluded by .gitignore.
  • The final command shows exactly what the first commit contains.

You will see the project source staged for commit. You will not see .env in the staged file list.

Is .env listed for commit?

Do not continue while .env is staged. Confirm that .gitignore contains the exact line .env.

Use help me remove .env from the staged Git files before creating the commit.

  • Create the first repository snapshot by running this command:
git commit -m "First commit"

What does this command do?

The commit records the staged project files as one versioned snapshot. Render will build the application from this snapshot after you push it.

The terminal will report a new root commit. Your deployable source is now recorded locally.

  • Go to GitHub in your browser.
  • Sign in to your GitHub account if prompted.
  • Open the create menu in the upper-right corner.
  • Click New repository.
  • Enter fastapi-portfolio as the repository name.
  • Choose the repository visibility that fits your portfolio.
  • Leave the options for a README file and generated ignore file disabled.
  • Click Create repository.

GitHub opens the empty repository's Quick Setup page. This page provides the remote URL for your repository.

  • Copy the HTTPS remote URL from the Quick Setup page.
  • Record the copied URL here: https://github.com/your-username/fastapi-portfolio.git.
  • Return to the Cursor terminal.
  • Connect the local repository and push its main branch by running these commands:
git remote add origin [[GITHUB_REPO_URL="https://github.com/your-username/fastapi-portfolio.git"]]
git remote -v
git push -u origin main

What do these commands do?

  • The first command associates the GitHub repository with the remote name origin.
  • The second command prints the fetch and push destinations for verification.
  • The final command uploads main and records origin/main as its upstream branch.

Refresh the GitHub repository page. You will see main.py and the project folders, while .env remains absent.

Did the GitHub push fail?

Confirm that the printed remote URL points to your fastapi-portfolio repository. Correct the remote before trying another push if the URL is wrong.

GitHub may request command-line authentication during the first push. Complete the browser or credential-manager prompt that Git opens.

If the repository still stays empty, help me diagnose my GitHub push.

Deploy the app and register its URL

A Render Web Service runs the FastAPI server at a public HTTPS address. Render installs the pinned dependencies before starting Uvicorn.

  • Return to the Render dashboard from earlier.
  • Click + New.
  • Select Web Service.
  • Click GitHub if Render asks you to connect a Git provider.
  • Authorize Render to access the fastapi-portfolio repository if prompted.
  • Select the fastapi-portfolio repository.
  • Click Connect.

Render now shows the Web Service creation form. These settings define how it builds and runs your Python application.

  • Enter fastapi-portfolio in the Name field.
  • Select Python 3 as the Language.
  • Set Branch to main.
  • Set the Build Command to this value:
pip install -r requirements.txt

What does the build command do?

Render installs every pinned package from requirements.txt. This reproduces the dependency set you tested locally.

  • Set the Start Command to this value:
uvicorn main:app --host 0.0.0.0 --port $PORT

What does the start command do?

Uvicorn loads the FastAPI application from main.py. The host setting accepts public traffic.

Render supplies $PORT for the running service. The command binds the server to that assigned port.

Choosing compute affects billing. Select the Free compute plan for this project.

  • Select the Free compute plan.
  • Find the Environment Variables section.
  • Add SUPABASE_URL as the first environment-variable key.
  • Paste its value from your local .env file into the corresponding value field.
  • Add SUPABASE_KEY as the second environment-variable key.
  • Paste its value from your local .env file into the corresponding value field.

The first deployment can spend several minutes installing packages and starting the service. A quiet build log during that period does not mean the deployment has stalled.

  • Click Create Web Service.
  • Follow the deployment progress on the service's Deploys page.

You will see the deployment status change to Live. Render also shows the service's public onrender.com URL.

  • Record the public service URL here: https://your-service.onrender.com.

Expect a cold start

A Free Render Web Service spins down after 15 minutes without inbound traffic. Its next request can take about one minute while the service starts again.

Did the deployment fail?

Check the deployment logs for the build or start stage that stopped. Confirm that the commands match the values above exactly.

Confirm that both SUPABASE_URL and SUPABASE_KEY exist in the service environment. Missing configuration prevents main.py from starting.

If the cause is still unclear, help me diagnose my Render deployment logs.

Your service is public, but OAuth redirects only to registered locations. Add the live origin to Google and Supabase before testing creator sign-in.

  • Return to Google Auth Platform from earlier.
  • Select Clients.
  • Open your Web application OAuth client.
  • Add https://your-service.onrender.com under Authorized JavaScript origins.
  • Save the OAuth client changes.

Keep the existing Supabase callback under Authorized redirect URIs. Google still returns the OAuth response to Supabase before the browser returns to your portfolio.

  • Return to the Supabase dashboard.
  • Select Authentication.
  • Open URL Configuration.
  • Set Site URL to https://your-service.onrender.com.
  • Click Add URL under Redirect URLs.
  • Enter https://your-service.onrender.com as the allowed redirect URL.
  • Save the authentication URL changes.

Before you test the public deployment, do you expect the same resume and creator workflow to survive outside your local computer?

  • Open a private browser window.
  • Navigate to https://your-service.onrender.com.
  • Scroll to the Resume section.
  • Click View PDF.

You will see the uploaded PDF at its public Supabase Storage URL. This proves the deployed application can render persistent content outside Render's filesystem.

  • Return to the live portfolio.
  • Scroll to the Contact section.
  • Enter Live portfolio test in the Name field.
  • Enter live-test@example.com in the Email field.
  • Enter Live inbox verification. in the Message field.
  • Click Send message.

You will see Message sent on the live site. The deployed public endpoint has written the message to Supabase.

  • Click Creator sign in.
  • Choose the Google account that matches CREATOR_EMAIL.
  • Scroll to the Private inbox.

You will see Live portfolio test beside the submitted message. That completes the public visitor-to-private creator workflow.

Does live Google sign-in fail?

OAuth configuration changes can take five minutes to a few hours to propagate. Wait briefly before repeating the sign-in flow.

Confirm that the Render origin in Google matches the recorded live URL exactly. Confirm that the same URL appears in Supabase Redirect URLs.

If the redirect still fails, help me compare my live OAuth URLs.

Final checkpoint

  • The public resumes bucket contains resume.pdf.
  • The fastapi-portfolio GitHub repository contains the deployable source without .env.
  • The Render Web Service uses the required build command and start command.
  • The service environment contains SUPABASE_URL and SUPABASE_KEY.
  • The live URL loads the portfolio and public resume.
  • The live contact form writes messages that only the creator can read.

That is the full product live. Visitors can inspect your work, open your resume, and contact you through a creator-protected application.

Secret mission

Reply to Visitor Messages

Turn the private inbox into a two-way workflow. Add a creator-only reply endpoint that saves each response in Supabase. Prove each reply survives a reload.

Clean Up Your Resources

Clean Up Your Resources

Choose whether to keep your portfolio live, pause public access, or remove every project resource. This build uses a Render Free Web Service, while costs for stored data depend on your existing Supabase plan.

Resources you used:

  • Cloud resource: A Render Web Service that deploys the portfolio at a public onrender.com URL.
  • Cloud resources: Supabase tables named projects and messages with Row Level Security policies.
  • Cloud resource: A public Supabase Storage bucket named resumes containing resume.pdf.
  • Authentication configuration: A Google Auth Platform Web application OAuth client with project-specific origins and redirect settings.
  • Source resource: A GitHub repository named fastapi-portfolio.
  • Local files: The fastapi-portfolio folder containing your project source and local .env file.

Keep everything running

No action is needed. Choose this if you want recruiters or clients to keep accessing the portfolio.

  • Confirm that your current Supabase plan can hold the portfolio tables and resume file.
  • Leave the Render Web Service deployed.
  • Leave the Supabase projects and messages tables intact.
  • Keep the resumes bucket available for public resume viewing.
  • Keep the Google OAuth client active for creator sign-in.
  • Keep the fastapi-portfolio repository on GitHub.
  • Keep the local fastapi-portfolio folder for future development.

The Render Free Web Service spins down after 15 minutes without inbound traffic. Your persistent projects, messages, and replies remain in Supabase.

Pause - I'll come back to this later

Let the public service spin down while preserving your code and stored content. You can wake the service later by visiting its public URL.

  • Stop sending inbound traffic to the public Render URL.
  • Remove your live onrender.com URL from the Supabase redirect allow list.
  • Remove your live onrender.com URL from the Google OAuth client's authorized origin list.
  • Keep the local app URL registered for future development.
  • Keep the Render Web Service so it can spin up again.
  • Keep the Supabase tables and Storage bucket so your content persists.
  • Keep the GitHub repository and local project folder.

Render spins the Free Web Service down after 15 minutes without inbound traffic. The first request after that can take about one minute while the service starts.

Delete - I don't want to use this again

Deleting these resources is permanent. The sequence below targets the portfolio resources while leaving the rest of your existing Supabase project intact.

Delete the Render Web Service
  • Open the Render dashboard.
  • Select the Web Service that deploys fastapi-portfolio.
  • Open the service settings page.
  • Locate the Web Service deletion control at the bottom of the page.
  • Enter the requested confirmation value.
  • Confirm that the service no longer appears in your Render dashboard.
  • Confirm that the former onrender.com URL no longer serves the portfolio.
Delete the Google OAuth client
  • Return to Google Auth Platform.
  • Open Clients.
  • Select the Web application OAuth client created for the portfolio.
  • Use the client deletion control.
  • Confirm that the portfolio client no longer appears in the client list.
Remove the Supabase authentication settings
  • Return to the redirect allow list in your Supabase project.
  • Remove the local portfolio URL.
  • Remove the live onrender.com URL.
  • Open the Google provider configuration used for this portfolio.
  • Clear the deleted OAuth client's credentials.
  • Save the provider configuration.
Delete the resume bucket
  • Open Storage in your existing Supabase project.
  • Select the resumes bucket.
  • Select resume.pdf.
  • Use the object deletion action.
  • Return to the Storage bucket list.
  • Use the bucket deletion action for resumes.
  • Confirm that the resumes bucket no longer appears.
Delete the Supabase tables
  • Open Table Editor in the same Supabase project.
  • Select the messages table.
  • Open the table actions menu.
  • Use the table deletion control.
  • Repeat the table deletion process for projects.
  • Confirm that neither portfolio table appears in the Table Editor.
Delete the GitHub repository
  • Open the fastapi-portfolio repository on GitHub.
  • Open the repository Settings.
  • Locate the repository deletion area at the bottom of the settings page.
  • Use the repository deletion control.
  • Enter fastapi-portfolio when GitHub requests the repository name.
  • Confirm that the repository URL no longer opens.
Delete the local project folder
  • Close the Cursor window that has fastapi-portfolio open.
  • Use File Explorer to navigate to the folder's existing location.
  • Select the fastapi-portfolio folder.
  • Delete the folder.
  • Confirm that fastapi-portfolio no longer appears in File Explorer.

Nice Work!

Nice Work!

You did it! Your authenticated FastAPI portfolio CMS is now live on Render at a public URL.

You've learned how to:

  • Render a responsive portfolio with FastAPI plus Jinja templates. Load project cards from Supabase data instead of hard-coded content.
  • Secure creator-only project CRUD with Google OAuth. Validate each creator JWT in FastAPI. Enforce write access with Postgres Row Level Security.
  • Publish a PDF resume for viewing or downloading. Receive visitor messages through a public contact form. Read them safely in a private creator inbox on Render.
  • Secret Mission: Added persistent creator replies beside visitor messages. Each reply now persists through the protected Supabase update workflow.

Ready to quiz yourself?