Home / Articles / Supabase GraphQL Direct vs Your Own Apollo Backend

This article is published in English.

Supabase GraphQL Direct vs Your Own Apollo Backend

Compare pg_graphql straight from Supabase with a separate Apollo server—auth, RLS, subscriptions, and when a hybrid Edge Function path wins.

1853 words

GraphQL is a query language, not a runtime. You can expose it from more than one place:

  • Supabase’s built-in pg_graphql — schema generated from PostgreSQL, hosted by Supabase, almost no config
  • A dedicated backend — Node, Python, or Go running Apollo Server, Hasura, Pothos, or similar that you own and deploy

The frontend can still be Apollo Client either way. What changes is where the GraphQL schema lives and who owns it.

The Vision Expert System takes the Supabase-direct path:

// src/client/supabase-grphql-apollo.client.js
const httpLink = new HttpLink({
  uri: `${SUPABASE_URL}/graphql/v1`,  // ← Supabase's built-in endpoint
});

No Express process, no Apollo Server, no hand-written resolvers, no second deploy. The API surface is the database schema.

Architecture sketches

Approach A: Supabase-direct

The browser talks straight to Supabase GraphQL. Auth is a Supabase JWT. Row-Level Security is the gate.

Approach B: Separate backend

The browser talks to your GraphQL server. Resolvers reach Supabase (or other sources) with an admin SDK or SQL. You invent the types and fields.

Side-by-side intuition

Direct mode optimizes for shipping CRUD without hosting an API. Separate mode optimizes for custom fields, multi-service joins, and GraphQL-native subscriptions. Same Apollo client patterns; different ownership of schema and policy.

Supabase-direct in practice

How it works

On startup, pg_graphql introspects PostgreSQL and emits a full GraphQL API. Tables become Collection types with CRUD shaped for Relay-style connections.

Apollo client config (this project)

// Two required headers — no custom backend token scheme
const authLink = setContext((_, { headers }) => ({
  headers: {
    ...headers,
    apikey: SUPABASE_ANON_KEY,       // identifies the Supabase project
    Authorization: `Bearer ${accessToken}`, // Supabase JWT for RLS
    "Content-Type": "application/json",
  },
}));
const httpLink = new HttpLink({
  uri: `${SUPABASE_URL}/graphql/v1`, // Supabase endpoint — no server to host
});

Query shape

Collections follow the connection pattern:

query LoadStaff {
  staffCollection {        # ← always "Collection"
    edges {                # ← always "edges"
      node {               # ← always "node"
        id
        first_name
        role {             # ← relations via FK are auto-resolved
          role_name
        }
      }
    }
  }
}

Mutation shape

mutation AddStaff($firstName: String!, $email: String!) {
  insertIntostaffCollection(         # ← prefix is always "insertInto"
    objects: { first_name: $firstName, email: $email }
  ) {
    records {
      id
    }
  }
}

Security at the database

RLS policies decide who sees what. The “backend” is PostgreSQL:

-- Only active staff can be read
CREATE POLICY "select active staff"
ON vision_expert.staff
FOR SELECT
USING (is_active = true AND auth_user_id = auth.uid());

Even a crafted GraphQL query cannot walk past RLS; filtering happens inside Postgres.

Limits of going direct

  1. Custom fields that are not in the DB need a function or view.
  2. Computed/virtual fields need computed columns or views.
  3. Enriching a single resolver with a third-party REST call is awkward without Edge Functions.
  4. Multi-step workflows with rollbacks live in DB functions or in the frontend.
  5. Naming follows pg_graphql conventions — underscores stay, so names like head_officeCollection appear.

Separate backend in practice

How it works

You run Apollo Server (or similar), author a schema, and write resolvers that call Supabase admin, Prisma, or SQL.

Apollo client config

// Separate backend — your own auth token, your own endpoint
const authLink = setContext((_, { headers }) => ({
  headers: {
    ...headers,
    Authorization: `Bearer ${yourCustomJwt}`,
    "Content-Type": "application/json",
  },
}));
const httpLink = new HttpLink({
  uri: "https://api.yourapp.com/graphql",  // your server
});

Custom schema

# You define every type by hand
type Staff {
  id: ID!
  firstName: String!
  lastName: String!
  fullName: String!          # computed field — not in DB
  role: Role!
  branch: Branch!
  subordinates: [Staff!]!   # custom logic
}
type Query {
  staff(id: ID!): Staff
  staffByBranch(branchId: ID!): [Staff!]!
}type Mutation {
  addStaff(input: AddStaffInput!): StaffPayload!
  deactivateStaff(id: ID!): Boolean!
}

Resolvers

// server/resolvers/staffResolvers.js
const Query = {
  staffByBranch: async (_, { branchId }, { supabaseAdmin, currentUser }) => {
    // Business logic: only managers can list their own branch
    if (currentUser.role !== "manager" && currentUser.branchId !== branchId) {
      throw new ForbiddenError("Access denied");
    }
    // Fetch from Supabase using admin SDK (bypasses RLS)
    const { data, error } = await supabaseAdmin
      .from("staff")
      .select("*, branch(*), role(*)")
      .eq("branch_id", branchId);    return data;
  },
};const Staff = {
  // Computed field not in DB
  fullName: (parent) => `${parent.first_name} ${parent.last_name}`,
};

Admin SDK paths bypass RLS, so authorization must live in resolver code with discipline.

Deep dive: what actually differs

1. Authentication flow

Supabase-direct:

User logs in → supabase.auth.signInWithPassword()
             → Supabase returns JWT
             → JWT stored in session
             → Apollo authLink reads JWT from supabase.auth.getSession()
             → JWT sent in Authorization header with every GraphQL request
             → Supabase validates JWT and enforces RLS
// Token is read dynamically from the Supabase session
let accessToken = null;
supabase.auth.getSession().then(({ data: { session } }) => {
  accessToken = session?.access_token ?? null;
});
supabase.auth.onAuthStateChange((_event, session) => {
  accessToken = session?.access_token ?? null;
});

Separate backend:

User logs in → POST /auth/login (your endpoint) or Supabase Auth
             → Your server returns a custom JWT
             → Frontend stores JWT
             → Apollo authLink reads JWT from localStorage / context
             → JWT sent to your server
             → Your server validates JWT in middleware
             → Resolver runs with the validated user context

Direct mode needs no token exchange — the Supabase JWT is both login proof and database credential. A separate backend adds a hop but lets you shape claims and lifetimes.

2. Apollo link wiring

Supabase-direct:

// Two specific headers, one endpoint
const link = offlineMutationLink
  .concat(authLink)   // adds apikey + Bearer
  .concat(httpLink);  // → supabase.co/graphql/v1

Separate backend:

// One standard auth header, your endpoint
const link = authLink    // adds your token
  .concat(httpLink);     // → api.yourapp.com/graphql

Same setContext + HttpLink idea. Differences are the URL and which headers are mandatory.

3. Queries and mutations

Supabase-direct — generated names are fixed:

# Table: staff → Query: staffCollection, Mutation: insertIntostaffCollection
query {
  staffCollection(filter: { is_active: { eq: true } }) {
    edges { node { id first_name } }
  }
}

Separate backend — you name everything:

# You chose the name "activeStaff"
query {
  activeStaff {
    id
    fullName          # computed in resolver
    avatarUrl         # fetched from a CDN API in resolver
  }
}

A custom schema can flatten nests, join services, and compute derived fields in one response. Direct mode returns what the tables hold.

4. Where business rules live

Supabase-direct:

Business logic →  PostgreSQL functions, triggers, and RLS policies
                  OR handled in the React frontend (as in this project)

Example — duplicate NIC/email check in the frontend before insert:

// Frontend handles the business rule: check for dup NIC/email before inserting
const { data: dupData } = await checkDuplicate({ variables: { nic, email } });
const nicExists = dupData?.nicCheck?.edges?.length > 0;
if (nicExists) {
  alert("A staff member with this NIC already exists.");
  return;
}
await addStaff({ variables: { ... } });

Separate backend:

// Backend resolver handles the business rule
const addStaff = async (_, { input }, { db }) => {
  const existing = await db.staff.findFirst({
    where: { OR: [{ nic: input.nic }, { email: input.email }] }
  });
  if (existing) throw new UserInputError("NIC or email already exists");
  // Atomic transaction: create auth user + insert staff profile
  return await db.$transaction([
    supabaseAdmin.auth.admin.createUser({ email: input.email }),
    db.staff.create({ data: input }),
  ]);
};

Backend rules stay centralized for every consumer (web, mobile, partners). Frontend-only rules scatter and are easy to skip.

5. Security model

Direct mode is harder to misconfigure for accidental data leaks because Postgres enforces access. A separate backend is more flexible and more dependent on careful resolvers.

6. Subscriptions and realtime

Supabase-direct uses Supabase Realtime channels, not GraphQL subscriptions:

// Supabase Realtime — separate from GraphQL
const channel = supabase
  .channel("staff-changes")
  .on("postgres_changes", { event: "*", schema: "vision_expert", table: "staff" },
    (payload) => { refetch(); }
  )
  .subscribe();

That path is separate from Apollo; the GraphQL client does not get live pushes by itself.

Separate backend can offer true GraphQL subscriptions over WebSocket:

subscription {
  staffAdded {
    id
    first_name
    role { role_name }
  }
}
// Apollo Client with WebSocket link
import { GraphQLWsLink } from "@apollo/client/link/subscriptions";
import { createClient } from "graphql-ws";
const wsLink = new GraphQLWsLink(
  createClient({ url: "wss://api.yourapp.com/graphql" })
);

7. Offline support

Both sides can queue offline mutations through Apollo Link. Vision Expert System does this on the direct path with RxDB as the local queue.

The pattern is the same in spirit:

Offline mutation → ApolloLink intercepts → stores in local DB → syncs on reconnect

Token refresh on reconnect is usually simpler when you own the token lifecycle. With Supabase you must confirm the JWT is still valid before flushing the queue.

When Supabase-direct fits

Prefer it when:

  • Workloads are CRUD-heavy and table-shaped
  • You want to ship without running an API host
  • The team is small and backend depth is limited
  • Logic fits PostgreSQL functions and RLS
  • There is essentially one frontend consumer
  • You want near-zero hosting cost for the API layer
  • Queries stay table-centric without cross-service joins

That matches Vision Expert System: an internal multi-role tool, all data in Supabase, RLS by role and branch, one frontend.

When a separate backend earns its keep

Add one when:

  • You need computed/virtual fields absent from the DB
  • One GraphQL response must aggregate Supabase plus payments, CRM, or other services
  • Multi-step transactions must stay atomic across tables and services
  • External developers will call a public API
  • You need GraphQL subscriptions as first-class push
  • Business rules are too heavy for SQL
  • GraphQL becomes a federation gateway in a microservice estate
  • You need API-level rate limits, validation, or audit logs
  • Generated pg_graphql names are unacceptable and unfixable

Hybrid path

Many production apps mix both:

  • Direct GraphQL for ordinary reads and writes
  • Supabase Edge Functions for the awkward 10%: admin SDK (safe RLS bypass), third-party calls, multi-step flows
Frontend Apollo Client
    │
    ├── /graphql/v1  →  Supabase pg_graphql  (standard CRUD)
    │
    └── /functions/complex-operation  →  Supabase Edge Function (business logic)
                                          → calls Supabase admin SDK internally

You keep the speed and low maintenance of direct mode for most traffic, and reserve backend-shaped flexibility for the operations that actually need it — without standing up a full always-on server for everything.