Why GraphQL Over REST
REST APIs force you to over-fetch or under-fetch. Your mobile app gets 20 fields when it needs 3. Your dashboard needs data from 4 endpoints, making 4 round trips. In Africa, where mobile data costs ₦1/MB and 3G is still common in many areas, every unnecessary byte matters.
GraphQL solves this: the client asks for exactly what it needs in one request. One round trip. No over-fetching. No under-fetching.
| Factor | REST | GraphQL |
|---|---|---|
| Data fetching | Fixed response shape per endpoint | Client specifies exact fields |
| Multiple resources | Multiple requests | Single query |
| API documentation | Separate (Swagger/OpenAPI) | Built-in schema + playground |
| Versioning | v1, v2, v3... | Evolve schema without breaking |
| Learning curve | Low | Medium |
| Best for | Simple CRUD | Complex, relational data |

The Free Stack
| Tool | Purpose | Free Limit | Cost After |
|---|---|---|---|
| Apollo Server | GraphQL server | Free forever | Free forever |
| Node.js | Runtime | Free forever | Free forever |
| Supabase (PostgreSQL) | Database | 500MB, 50k users | $25/month |
| Railway | Hosting | $5 free credits | $5/month |
| GraphQL Playground | API explorer | Free forever | Free forever |
Total cost: $0/month
Step 1: Scaffold the Project
mkdir graphql-api && cd graphql-api
npm init -y
npm install @apollo/server graphql cors express
npm install @supabase/supabase-js jsonwebtoken dotenv
npm install --save-dev nodemon
{
"type": "module",
"scripts": {
"dev": "nodemon index.js",
"start": "node index.js"
}
}
Step 2: Define Your Schema
The schema is your API contract. Write it before any code:
// schema.js
export const typeDefs = `#graphql
type User {
id: ID!
email: String!
name: String
plan: String!
createdAt: String!
projects: [Project!]!
}
type Project {
id: ID!
name: String!
description: String
status: String!
owner: User!
tasks: [Task!]!
createdAt: String!
}
type Task {
id: ID!
title: String!
done: Boolean!
project: Project!
createdAt: String!
}
type AuthPayload {
token: String!
user: User!
}
type Query {
me: User
user(id: ID!): User
projects(status: String): [Project!]!
project(id: ID!): Project
tasks(projectId: ID!, done: Boolean): [Task!]!
}
type Mutation {
signup(email: String!, password: String!, name: String): AuthPayload!
login(email: String!, password: String!): AuthPayload!
createProject(name: String!, description: String): Project!
updateProject(id: ID!, name: String, status: String): Project!
deleteProject(id: ID!): Boolean!
createTask(projectId: ID!, title: String!): Task!
toggleTask(id: ID!): Task!
}
`
Step 3: Write Resolvers
Resolvers tell Apollo how to fetch data for each field:
// resolvers.js
import jwt from 'jsonwebtoken'
import { supabaseAdmin } from './lib/supabase.js'
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production'
function getUserFromToken(authHeader) {
if (!authHeader) return null
const token = authHeader.replace('Bearer ', '')
try {
return jwt.verify(token, JWT_SECRET)
} catch {
return null
}
}
export const resolvers = {
Query: {
me: (_, __, { user }) => {
if (!user) throw new Error('Not authenticated')
return supabaseAdmin.from('users').select('*').eq('id', user.id).single()
},
projects: async (_, { status }, { user }) => {
if (!user) throw new Error('Not authenticated')
let query = supabaseAdmin.from('projects').select('*').eq('owner_id', user.id)
if (status) query = query.eq('status', status)
const { data } = await query.order('created_at', { ascending: false })
return data
},
project: async (_, { id }, { user }) => {
if (!user) throw new Error('Not authenticated')
const { data } = await supabaseAdmin.from('projects').select('*').eq('id', id).eq('owner_id', user.id).single()
return data
},
tasks: async (_, { projectId, done }, { user }) => {
if (!user) throw new Error('Not authenticated')
let query = supabaseAdmin.from('tasks').select('*').eq('project_id', projectId)
if (done !== undefined) query = query.eq('done', done)
const { data } = await query.order('created_at', { ascending: false })
return data
}
},
Mutation: {
signup: async (_, { email, password, name }) => {
const { data, error } = await supabaseAdmin.auth.admin.createUser({
email, password, email_confirm: true
})
if (error) throw new Error(error.message)
await supabaseAdmin.from('users').insert({ id: data.user.id, email, name, plan: 'free' })
const token = jwt.sign({ id: data.user.id, email }, JWT_SECRET, { expiresIn: '7d' })
return { token, user: { id: data.user.id, email, name, plan: 'free' } }
},
login: async (_, { email, password }) => {
const { data, error } = await supabaseAdmin.auth.signInWithPassword({ email, password })
if (error) throw new Error('Invalid credentials')
const { data: profile } = await supabaseAdmin.from('users').select('*').eq('id', data.user.id).single()
const token = jwt.sign({ id: data.user.id, email }, JWT_SECRET, { expiresIn: '7d' })
return { token, user: profile }
},
createProject: async (_, { name, description }, { user }) => {
if (!user) throw new Error('Not authenticated')
const { data } = await supabaseAdmin.from('projects')
.insert({ name, description, owner_id: user.id, status: 'active' })
.select().single()
return data
},
toggleTask: async (_, { id }, { user }) => {
if (!user) throw new Error('Not authenticated')
const { data: task } = await supabaseAdmin.from('tasks').select('done').eq('id', id).single()
const { data } = await supabaseAdmin.from('tasks').update({ done: !task.done }).eq('id', id).select().single()
return data
}
},
User: {
projects: async (parent) => {
const { data } = await supabaseAdmin.from('projects').select('*').eq('owner_id', parent.id)
return data
}
},
Project: {
owner: async (parent) => {
const { data } = await supabaseAdmin.from('users').select('*').eq('id', parent.owner_id).single()
return data
},
tasks: async (parent) => {
const { data } = await supabaseAdmin.from('tasks').select('*').eq('project_id', parent.id)
return data
}
}
}
Step 4: Wire Up the Server
// index.js
import 'dotenv/config'
import { ApolloServer } from '@apollo/server'
import { startStandaloneServer } from '@apollo/server/standalone'
import { typeDefs } from './schema.js'
import { resolvers } from './resolvers.js'
const server = new ApolloServer({ typeDefs, resolvers })
const { url } = await startStandaloneServer(server, {
listen: { port: process.env.PORT || 4000 },
context: async ({ req }) => {
const authHeader = req.headers.authorization || ''
const user = getUserFromToken(authHeader)
return { user }
}
})
console.log('GraphQL API ready at ' + url)
Step 5: Create the Database Schema
-- Supabase SQL Editor
create table users (
id uuid primary key,
email text unique not null,
name text,
plan text default 'free',
created_at timestamptz default now()
);
create table projects (
id uuid primary key default gen_random_uuid(),
name text not null,
description text,
status text default 'active',
owner_id uuid references users(id) on delete cascade,
created_at timestamptz default now()
);
create table tasks (
id uuid primary key default gen_random_uuid(),
title text not null,
done boolean default false,
project_id uuid references projects(id) on delete cascade,
created_at timestamptz default now()
);
alter table projects enable row level security;
alter table tasks enable row level security;
Step 6: Deploy to Railway
npm install -g @railway/cli
railway login
railway init
railway up
Set environment variables in Railway:
| Variable | Value |
|---|---|
| SUPABASE_URL | Your Supabase project URL |
| SUPABASE_SERVICE_KEY | Your service role key |
| JWT_SECRET | A random 32+ character string |
| PORT | Auto-set by Railway |
Your GraphQL API is live at https://your-app.railway.app. Open it in a browser and you'll see the Apollo Sandbox — an interactive playground for running queries.
Test It
mutation Signup {
signup(email: "test@example.com", password: "password123", name: "Test User") {
token
user { id email name }
}
}
query MyProjects {
projects {
id name status
tasks { id title done }
}
}
REST vs GraphQL: Real-World Comparison
| Metric | REST API | GraphQL API |
|---|---|---|
| Fetch user + projects + tasks | 3 requests | 1 query |
| Response size (user dashboard) | 12KB over-fetch | 3KB exact |
| API discoverability | Read Swagger docs | Built-in playground |
| Adding a new field | New endpoint or version | Add to schema, done |
| Mobile data usage (Nigeria 3G) | High | Low |
Common Mistakes
| Mistake | What Happens | Fix |
|---|---|---|
| N+1 queries in resolvers | One query triggers 100 sub-queries | Use DataLoader to batch database calls |
| No authentication on resolvers | Anyone can query any data | Check context.user in every resolver |
| Overly complex schema | Impossible to maintain | Keep schema flat, max 3 levels deep |
| No query depth limiting | Malicious queries can crash your server | Install graphql-depth-limit |
| Skipping the playground in production | Lost debugging tool | Apollo Sandbox works in production securely |

