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.
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
- Custom fields that are not in the DB need a function or view.
- Computed/virtual fields need computed columns or views.
- Enriching a single resolver with a third-party REST call is awkward without Edge Functions.
- Multi-step workflows with rollbacks live in DB functions or in the frontend.
- Naming follows
pg_graphqlconventions — underscores stay, so names likehead_officeCollectionappear.
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_graphqlnames 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.