How to Wire React to Supabase CRUD (Using the pg Client)

Supabase wraps Postgres with an auto-generated REST layer, but the underlying CRUD shape is just SQL. Building that layer yourself with the Node.js pg client gives you full control over queries and a drop-in swap path to @supabase/supabase-js when you're ready.

Fullstack Engineersupabasepostgrescrud

The CRUD shape Supabase mirrors

Supabase's PostgREST methods (supabase.from('profiles').select(), .insert(), .update(), .delete()) each map directly to a SQL statement your pg client can issue. Building the layer yourself first means you understand exactly what the abstraction does - and swapping clients later is just changing the connector, not the logic.

For a profiles table (id INT PK, name TEXT, bio TEXT), the four functions are:

const { Client } = require("pg");

async function _client() {
  const c = new Client({ host: "127.0.0.1", database: "app", user: "postgres" });
  await c.connect();
  return c;
}

async function createProfile(id, name, bio) {
  const c = await _client();
  try {
    await c.query(
      "INSERT INTO profiles (id, name, bio) VALUES ($1, $2, $3)",
      [id, name, bio]
    );
  } finally { await c.end(); }
}

async function getProfile(id) {
  const c = await _client();
  try {
    const r = await c.query("SELECT * FROM profiles WHERE id = $1", [id]);
    return r.rows[0] || null;
  } finally { await c.end(); }
}

async function updateProfile(id, name, bio) {
  const c = await _client();
  try {
    const r = await c.query(
      "UPDATE profiles SET name=$2, bio=$3 WHERE id=$1 RETURNING *",
      [id, name, bio]
    );
    return r.rows[0] || null;
  } finally { await c.end(); }
}

async function deleteProfile(id) {
  const c = await _client();
  try {
    await c.query("DELETE FROM profiles WHERE id = $1", [id]);
  } finally { await c.end(); }
}

module.exports = { createProfile, getProfile, updateProfile, deleteProfile };

Why parameterized queries ($1, $2, ...)

Never build SQL by concatenating user input. $1, $2 placeholders pass values separately from the query string - the driver handles escaping, so SQL injection is structurally impossible regardless of what the caller passes in.

Supabase's client enforces this automatically; rolling your own layer means you have to enforce it yourself. Every query above uses placeholders.

RETURNING * on UPDATE

Adding RETURNING * to the UPDATE statement gets the full updated row back in a single round trip, so the caller doesn't need a second SELECT. This mirrors what Supabase returns from .update().select().

Swap path to @supabase/supabase-js

Once the pg layer works, replacing it with the Supabase client is mechanical:

import { createClient } from "@supabase/supabase-js";
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);

// getProfile equivalent
const { data } = await supabase.from("profiles").select("*").eq("id", id).single();

The query logic stays the same - only the client changes. Production also layers Row-Level Security policies so each user only reads their own rows, and realtime channels to push changes to React state as they happen.

Want to try it hands-on? HeyDevJob gives you this exact setup in a live cloud workspace in your browser - edit it, run it, and see it work. Free, nothing to install.

Try it in a workspace →

What you'll practice

FAQ

How do I implement Supabase CRUD with the pg client in Node.js?

Create a pg Client per operation, issue parameterized INSERT/SELECT/UPDATE/DELETE queries using $1/$2 placeholders, and export the functions. The shape matches what @supabase/supabase-js exposes, so swapping clients later is straightforward.

Why use parameterized queries instead of string concatenation in SQL?

Parameterized queries ($1, $2, ...) send the query and values separately - the driver handles escaping, making SQL injection structurally impossible. Never interpolate user-controlled values directly into a query string.

How do I get the updated row back after an UPDATE in PostgreSQL?

Add RETURNING * to the UPDATE statement. The pg client returns the updated row in r.rows[0], giving you the new data in one round trip without a separate SELECT.

Keep learning

Fix a React useState Stale-State BugFullstack projectFix a Blank React Dashboard (Failed Fetch)Fullstack projectFix the React Search That Won't Filter (useEffect Deps)Fullstack projectFullstack roadmapStep by step to hiredFullstack interview questionsSTAR answersAll Fullstack projectsProjects hub

Learn it by doing. Open this in a live cloud workspace, make the change yourself, and keep a record of the work you can share.

Open the workspace →