Skip to main content

Grafbase vs Hasura vs PostGraphile: Instant GraphQL APIs (2026)

·PkgPulse Team

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

FeatureGrafbaseHasuraPostGraphile
Schema approachSchema-firstAuto-generatedAuto-generated
Data sourceBuilt-in + connectorsPostgreSQLPostgreSQL
Query languageGraphQLGraphQLGraphQL
SubscriptionsSSE✅ (WebSocket)✅ (plugin)
AuthorizationBuilt-in JWTRow-level permissionsPostgreSQL RLS
Custom resolversTypeScriptActions (webhooks)SQL functions + plugins
Edge deployment✅ (native)
Caching✅ (edge cache)❌ (external)❌ (external)
Event triggersVia PostgreSQL
Remote schemas✅ (connectors)Via plugins
AggregationsVia 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).

Compare GraphQL tools and developer APIs on PkgPulse →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.