Grafbase vs Hasura vs PostGraphile: Instant GraphQL APIs (2026)
TL;DR
Grafbase is the edge-native GraphQL platform — schema-first, edge deployments, built-in auth, connectors to databases and APIs, TypeScript resolvers, real-time with SSE. Hasura is the instant GraphQL engine — auto-generates GraphQL from PostgreSQL, real-time subscriptions, authorization with row-level security, remote schemas, event triggers. PostGraphile is the PostgreSQL GraphQL server — auto-generates a performant GraphQL API from your database schema, plugins, smart comments, zero-config. In 2026: Grafbase for edge-first GraphQL, Hasura for instant Postgres-backed GraphQL, PostGraphile for pure PostgreSQL-to-GraphQL.
Key Takeaways
- Grafbase: Edge-deployed, schema-first, TypeScript resolvers, connectors
- Hasura: hasura-cli — instant GraphQL from Postgres, real-time, event triggers
- PostGraphile: postgraphile ~20K weekly downloads — Postgres-native, plugin system
- Grafbase deploys at the edge with built-in auth and caching
- Hasura provides the fastest path from Postgres to production GraphQL API
- PostGraphile generates the most optimized queries directly from Postgres schema
Grafbase
Grafbase — edge-native GraphQL:
Schema definition
# grafbase/schema.graphql
type Package @model {
name: String!
description: String
downloads: Int!
version: String!
tags: [String!]!
author: Author @relation
createdAt: DateTime!
}
type Author @model {
name: String!
email: String! @unique
packages: [Package!]! @relation
bio: String
}
type Query {
featuredPackages: [Package!]! @resolver(name: "featured")
searchPackages(query: String!): [Package!]! @resolver(name: "search")
}
type Mutation {
incrementDownloads(packageId: ID!): Package @resolver(name: "incrementDownloads")
}
TypeScript resolvers
// grafbase/resolvers/featured.ts
export default async function FeaturedResolver(_, __, { g }) {
const { packageCollection } = await g.query({
packageCollection: {
__args: {
first: 10,
orderBy: { downloads: "DESC" },
},
edges: {
node: {
id: true,
name: true,
description: true,
downloads: true,
version: true,
tags: true,
author: {
name: true,
},
},
},
},
})
return packageCollection.edges.map((e: any) => e.node)
}
// grafbase/resolvers/search.ts
export default async function SearchResolver(
_,
{ query }: { query: string },
{ g }
) {
const { packageSearch } = await g.query({
packageSearch: {
__args: {
query,
first: 20,
fields: ["name", "description"],
},
edges: {
node: {
id: true,
name: true,
description: true,
downloads: true,
version: true,
},
score: true,
},
},
})
return packageSearch.edges.map((e: any) => e.node)
}
// grafbase/resolvers/incrementDownloads.ts
export default async function IncrementDownloadsResolver(
_,
{ packageId }: { packageId: string },
{ g }
) {
const pkg = await g.query({
package: {
__args: { by: { id: packageId } },
id: true,
downloads: true,
},
})
if (!pkg.package) throw new Error("Package not found")
const updated = await g.mutation({
packageUpdate: {
__args: {
by: { id: packageId },
input: { downloads: pkg.package.downloads + 1 },
},
package: {
id: true,
name: true,
downloads: true,
},
},
})
return updated.packageUpdate.package
}
Configuration and auth
// grafbase.config.ts
import { g, config, auth } from "@grafbase/sdk"
const provider = auth.JWT({
issuer: "https://auth.example.com",
secret: g.env("JWT_SECRET"),
})
export default config({
schema: g,
auth: {
providers: [provider],
rules: (rules) => {
rules.private() // All queries require auth by default
},
},
cache: {
rules: [
{
types: ["Package"],
maxAge: 60, // Cache for 60 seconds
staleWhileRevalidate: 300,
},
],
},
})
Client queries
// Querying Grafbase from a Next.js app:
const GRAFBASE_URL = process.env.GRAFBASE_API_URL!
const GRAFBASE_KEY = process.env.GRAFBASE_API_KEY!
async function graphql<T>(query: string, variables?: Record<string, any>): Promise<T> {
const response = await fetch(GRAFBASE_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": GRAFBASE_KEY,
},
body: JSON.stringify({ query, variables }),
})
const { data, errors } = await response.json()
if (errors) throw new Error(errors[0].message)
return data
}
// Fetch packages:
const data = await graphql<{ packageCollection: any }>(`
query GetPackages($first: Int!) {
packageCollection(first: $first, orderBy: { downloads: DESC }) {
edges {
node {
id
name
description
downloads
version
tags
author { name }
}
}
}
}
`, { first: 20 })
// Create package:
const result = await graphql<{ packageCreate: any }>(`
mutation CreatePackage($input: PackageCreateInput!) {
packageCreate(input: $input) {
package {
id
name
}
}
}
`, {
input: {
name: "react",
description: "UI library",
downloads: 25000000,
version: "19.0.0",
tags: ["frontend", "ui"],
},
})
Hasura
Hasura — instant GraphQL on Postgres:
Docker setup
# docker-compose.yml
version: "3.8"
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: secret
POSTGRES_DB: pkgpulse
volumes:
- pgdata:/var/lib/postgresql/data
hasura:
image: hasura/graphql-engine:v2.40.0
ports:
- "8080:8080"
environment:
HASURA_GRAPHQL_DATABASE_URL: postgresql://postgres:secret@postgres:5432/pkgpulse
HASURA_GRAPHQL_ENABLE_CONSOLE: "true"
HASURA_GRAPHQL_ADMIN_SECRET: myadminsecret
HASURA_GRAPHQL_JWT_SECRET: '{"type":"HS256","key":"your-jwt-secret-at-least-32-chars"}'
HASURA_GRAPHQL_UNAUTHORIZED_ROLE: anonymous
depends_on:
- postgres
volumes:
pgdata:
Auto-generated queries
-- Create tables — Hasura auto-generates GraphQL:
CREATE TABLE packages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
description TEXT,
downloads INTEGER DEFAULT 0,
version TEXT,
tags TEXT[] DEFAULT '{}',
author_id UUID REFERENCES authors(id),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE authors (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
bio TEXT
);
# Hasura auto-generates these queries and mutations:
# Query packages (with filtering, sorting, pagination):
query GetPackages {
packages(
order_by: { downloads: desc }
limit: 20
where: { downloads: { _gte: 1000000 } }
) {
id
name
description
downloads
version
tags
author {
name
email
}
created_at
}
}
# Aggregate queries:
query PackageStats {
packages_aggregate(where: { tags: { _contains: ["frontend"] } }) {
aggregate {
count
avg { downloads }
max { downloads }
sum { downloads }
}
}
}
# Insert:
mutation CreatePackage($object: packages_insert_input!) {
insert_packages_one(object: $object) {
id
name
}
}
# Update:
mutation IncrementDownloads($id: uuid!) {
update_packages_by_pk(
pk_columns: { id: $id }
_inc: { downloads: 1 }
) {
id
name
downloads
}
}
# Real-time subscription:
subscription WatchPackage($name: String!) {
packages(where: { name: { _eq: $name } }) {
name
downloads
version
}
}
Authorization (row-level security)
// Hasura permissions — set via console or metadata:
// packages table — "user" role:
{
"role": "user",
"permission": {
"select": {
"columns": ["id", "name", "description", "downloads", "version", "tags", "created_at"],
"filter": {},
"allow_aggregations": true
},
"insert": {
"columns": ["name", "description", "version", "tags"],
"check": {
"author_id": { "_eq": "X-Hasura-User-Id" }
},
"set": {
"author_id": "X-Hasura-User-Id"
}
},
"update": {
"columns": ["description", "version", "tags"],
"filter": {
"author_id": { "_eq": "X-Hasura-User-Id" }
}
},
"delete": {
"filter": {
"author_id": { "_eq": "X-Hasura-User-Id" }
}
}
}
}
Event triggers and actions
// Hasura Event Trigger — fires on data changes:
// POST /api/webhooks/package-created
import { NextApiRequest, NextApiResponse } from "next"
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { event } = req.body
if (event.op === "INSERT") {
const pkg = event.data.new
console.log(`New package: ${pkg.name}`)
// Send notification, update search index, etc.
await indexPackageForSearch(pkg)
await sendSlackNotification(`New package added: ${pkg.name}`)
}
if (event.op === "UPDATE") {
const { old: prev, new: current } = event.data
if (current.downloads !== prev.downloads) {
await updateAnalytics(current.id, current.downloads)
}
}
res.json({ success: true })
}
// Hasura Action — custom business logic:
// POST /api/actions/publish-package
export default async function publishPackage(req: NextApiRequest, res: NextApiResponse) {
const { input: { package_id }, session_variables } = req.body
const userId = session_variables["x-hasura-user-id"]
// Custom validation:
const pkg = await db.query("SELECT * FROM packages WHERE id = $1", [package_id])
if (pkg.rows[0].author_id !== userId) {
return res.status(403).json({ message: "Not authorized" })
}
// Publish to npm registry:
await publishToNpm(pkg.rows[0])
// Update status:
await db.query(
"UPDATE packages SET published = true, published_at = NOW() WHERE id = $1",
[package_id]
)
res.json({ success: true, published_at: new Date().toISOString() })
}
Client SDK
// Using Hasura with graphql-request:
import { GraphQLClient, gql } from "graphql-request"
const client = new GraphQLClient("http://localhost:8080/v1/graphql", {
headers: {
// Admin access:
"x-hasura-admin-secret": process.env.HASURA_ADMIN_SECRET!,
// Or JWT-based user access:
// Authorization: `Bearer ${userToken}`,
},
})
// Query with variables:
const { packages } = await client.request(gql`
query SearchPackages($query: String!, $limit: Int!) {
packages(
where: { name: { _ilike: $query } }
order_by: { downloads: desc }
limit: $limit
) {
id
name
description
downloads
version
tags
author { name }
}
}
`, { query: "%react%", limit: 10 })
// Subscription (via WebSocket):
import { createClient } from "graphql-ws"
const wsClient = createClient({
url: "ws://localhost:8080/v1/graphql",
connectionParams: {
headers: {
"x-hasura-admin-secret": process.env.HASURA_ADMIN_SECRET!,
},
},
})
const unsubscribe = wsClient.subscribe(
{
query: `
subscription WatchPopular {
packages(order_by: {downloads: desc}, limit: 5) {
name
downloads
}
}
`,
},
{
next: (data) => console.log("Update:", data),
error: (err) => console.error(err),
complete: () => console.log("Done"),
}
)
PostGraphile
PostGraphile — PostgreSQL to GraphQL:
Setup
npm install postgraphile
// server.ts — standalone PostGraphile server:
import { postgraphile } from "postgraphile"
import express from "express"
const app = express()
app.use(
postgraphile(process.env.DATABASE_URL!, "public", {
watchPg: true, // Auto-reload on schema changes
graphiql: true, // Enable GraphiQL IDE
enhanceGraphiql: true,
dynamicJson: true,
setofFunctionsContainNulls: false,
ignoreRBAC: false,
enableQueryBatching: true,
legacyRelations: "omit",
// Performance:
retryOnInitFail: true,
extendedErrors: ["hint", "detail", "errcode"],
// Plugins:
appendPlugins: [
require("@graphile-contrib/pg-simplify-inflector"),
require("@graphile/pg-aggregates").default,
],
// JWT auth:
jwtSecret: process.env.JWT_SECRET!,
jwtPgTypeIdentifier: "public.jwt_token",
defaultRole: "anonymous",
})
)
app.listen(5000, () => {
console.log("PostGraphile running at http://localhost:5000/graphql")
})
PostgreSQL schema (auto-generates GraphQL)
-- PostGraphile generates GraphQL from your Postgres schema:
CREATE TABLE packages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
description TEXT,
downloads INTEGER DEFAULT 0,
version TEXT,
tags TEXT[] DEFAULT '{}',
author_id UUID REFERENCES authors(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE authors (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
bio TEXT
);
-- Smart comments — control GraphQL schema:
COMMENT ON TABLE packages IS '@name Package';
COMMENT ON COLUMN packages.author_id IS '@foreignFieldName packages';
COMMENT ON COLUMN packages.created_at IS '@omit create,update';
-- Custom function → becomes a query field:
CREATE FUNCTION search_packages(query TEXT)
RETURNS SETOF packages AS $$
SELECT * FROM packages
WHERE name ILIKE '%' || query || '%'
OR description ILIKE '%' || query || '%'
ORDER BY downloads DESC
LIMIT 20;
$$ LANGUAGE sql STABLE;
-- Computed column → becomes a field:
CREATE FUNCTION packages_download_rank(p packages)
RETURNS INTEGER AS $$
SELECT COUNT(*)::INTEGER
FROM packages
WHERE downloads > p.downloads;
$$ LANGUAGE sql STABLE;
-- Custom mutation:
CREATE FUNCTION increment_downloads(package_id UUID)
RETURNS packages AS $$
UPDATE packages
SET downloads = downloads + 1
WHERE id = package_id
RETURNING *;
$$ LANGUAGE sql VOLATILE;
Auto-generated queries
# PostGraphile auto-generates all of these:
# Query with filtering and pagination:
query GetPackages {
packages(
orderBy: DOWNLOADS_DESC
first: 20
condition: { }
filter: { downloads: { greaterThanOrEqualTo: 1000000 } }
) {
nodes {
id
name
description
downloads
version
tags
downloadRank # From computed column
author {
name
email
}
}
totalCount
pageInfo {
hasNextPage
endCursor
}
}
}
# Search (from custom function):
query SearchPackages {
searchPackages(query: "react") {
nodes {
id
name
description
downloads
}
}
}
# Mutation (auto-generated):
mutation CreatePackage {
createPackage(input: {
package: {
name: "react"
description: "UI library"
downloads: 25000000
version: "19.0.0"
tags: ["frontend", "ui"]
}
}) {
package {
id
name
}
}
}
# Custom mutation (from function):
mutation IncrementDownloads {
incrementDownloads(input: { packageId: "..." }) {
package {
id
name
downloads
}
}
}
# Aggregates (with plugin):
query PackageStats {
packages {
aggregates {
sum { downloads }
average { downloads }
max { downloads }
distinctCount { name }
}
}
}
Row-level security
-- PostGraphile uses Postgres RLS natively:
-- Enable RLS:
ALTER TABLE packages ENABLE ROW LEVEL SECURITY;
-- JWT type for auth:
CREATE TYPE jwt_token AS (
role TEXT,
user_id UUID,
exp BIGINT
);
-- Login function:
CREATE FUNCTION authenticate(email TEXT, password TEXT)
RETURNS jwt_token AS $$
DECLARE
account authors;
BEGIN
SELECT * INTO account FROM authors WHERE authors.email = authenticate.email;
IF account.password_hash = crypt(password, account.password_hash) THEN
RETURN (
'app_user',
account.id,
EXTRACT(EPOCH FROM NOW() + INTERVAL '7 days')
)::jwt_token;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql STRICT SECURITY DEFINER;
-- Policies:
CREATE POLICY select_packages ON packages FOR SELECT
USING (true); -- Everyone can read
CREATE POLICY insert_packages ON packages FOR INSERT
WITH CHECK (author_id = current_setting('jwt.claims.user_id')::UUID);
CREATE POLICY update_own_packages ON packages FOR UPDATE
USING (author_id = current_setting('jwt.claims.user_id')::UUID);
CREATE POLICY delete_own_packages ON packages FOR DELETE
USING (author_id = current_setting('jwt.claims.user_id')::UUID);
-- Roles:
CREATE ROLE anonymous;
CREATE ROLE app_user;
GRANT SELECT ON packages TO anonymous;
GRANT SELECT, INSERT, UPDATE, DELETE ON packages TO app_user;
Library mode (Express/Fastify)
// Use PostGraphile as Express middleware:
import express from "express"
import { postgraphile, makePluginHook } from "postgraphile"
import PgSimplifyInflectorPlugin from "@graphile-contrib/pg-simplify-inflector"
import PgAggregatesPlugin from "@graphile/pg-aggregates"
const app = express()
// Custom plugin:
const MyPlugin = (builder) => {
builder.hook("GraphQLObjectType:fields", (fields, build, context) => {
if (context.scope.isRootQuery) {
return {
...fields,
serverTime: {
type: build.graphql.GraphQLString,
resolve: () => new Date().toISOString(),
},
}
}
return fields
})
}
app.use(
postgraphile(process.env.DATABASE_URL!, ["public"], {
appendPlugins: [
PgSimplifyInflectorPlugin,
PgAggregatesPlugin,
MyPlugin,
],
subscriptions: true,
simpleSubscriptions: true,
websocketMiddlewares: [],
graphileBuildOptions: {
pgOmitListSuffix: true,
},
})
)
app.listen(5000)
Feature Comparison
| Feature | Grafbase | Hasura | PostGraphile |
|---|---|---|---|
| Schema approach | Schema-first | Auto-generated | Auto-generated |
| Data source | Built-in + connectors | PostgreSQL | PostgreSQL |
| Query language | GraphQL | GraphQL | GraphQL |
| Subscriptions | SSE | ✅ (WebSocket) | ✅ (plugin) |
| Authorization | Built-in JWT | Row-level permissions | PostgreSQL RLS |
| Custom resolvers | TypeScript | Actions (webhooks) | SQL functions + plugins |
| Edge deployment | ✅ (native) | ❌ | ❌ |
| Caching | ✅ (edge cache) | ❌ (external) | ❌ (external) |
| Event triggers | ❌ | ✅ | Via PostgreSQL |
| Remote schemas | ✅ (connectors) | ✅ | Via plugins |
| Aggregations | Via resolvers | ✅ (built-in) | ✅ (plugin) |
| Migrations | ❌ (managed) | ✅ (CLI) | Via PostgreSQL |
| Console/IDE | ✅ (Pathfinder) | ✅ (web console) | ✅ (GraphiQL) |
| Self-hosted | ❌ (SaaS) | ✅ (Docker) | ✅ (npm) |
| Free tier | ✅ | ✅ (self-hosted) | ✅ (MIT license) |
When to Use Each
Use Grafbase if:
- Want edge-deployed GraphQL with built-in caching and auth
- Prefer schema-first development with TypeScript resolvers
- Need to unify multiple data sources behind a single GraphQL API
- Building JAMstack or edge-first applications
Use Hasura if:
- Want instant GraphQL API from an existing PostgreSQL database
- Need real-time subscriptions and event triggers out of the box
- Prefer declarative authorization with row-level permissions
- Building data-heavy applications where Postgres is the source of truth
Use PostGraphile if:
- Want a pure PostgreSQL-to-GraphQL layer with maximum performance
- Prefer PostgreSQL-native features (RLS, functions, computed columns)
- Need a fully open-source, self-hosted GraphQL server
- Building applications where database schema drives the API design
Methodology
Feature comparison based on Grafbase (2026), Hasura v2.40.x, and PostGraphile v4.x / Graphile Crystal (March 2026).