How to Build and Deploy a GraphQL API: Apollo Server
Build17 min read·March 14, 2026·--

How to Build and Deploy a GraphQL API: Apollo Server

Build a production GraphQL API with Apollo Server — schema design, resolvers, JWT authentication, and PostgreSQL via Supabase. Deploy to Railway for free with complete code included.

@
@kivorablog
March 14, 2026
Share

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.


FactorRESTGraphQL
Data fetchingFixed response shape per endpointClient specifies exact fields
Multiple resourcesMultiple requestsSingle query
API documentationSeparate (Swagger/OpenAPI)Built-in schema + playground
Versioningv1, v2, v3...Evolve schema without breaking
Learning curveLowMedium
Best forSimple CRUDComplex, relational data



The Free Stack


ToolPurposeFree LimitCost After
Apollo ServerGraphQL serverFree foreverFree forever
Node.jsRuntimeFree foreverFree forever
Supabase (PostgreSQL)Database500MB, 50k users$25/month
RailwayHosting$5 free credits$5/month
GraphQL PlaygroundAPI explorerFree foreverFree 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:


VariableValue
SUPABASE_URLYour Supabase project URL
SUPABASE_SERVICE_KEYYour service role key
JWT_SECRETA random 32+ character string
PORTAuto-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


MetricREST APIGraphQL API
Fetch user + projects + tasks3 requests1 query
Response size (user dashboard)12KB over-fetch3KB exact
API discoverabilityRead Swagger docsBuilt-in playground
Adding a new fieldNew endpoint or versionAdd to schema, done
Mobile data usage (Nigeria 3G)HighLow



Common Mistakes


MistakeWhat HappensFix
N+1 queries in resolversOne query triggers 100 sub-queriesUse DataLoader to batch database calls
No authentication on resolversAnyone can query any dataCheck context.user in every resolver
Overly complex schemaImpossible to maintainKeep schema flat, max 3 levels deep
No query depth limitingMalicious queries can crash your serverInstall graphql-depth-limit
Skipping the playground in productionLost debugging toolApollo Sandbox works in production securely
Read more on Kivora Blog

Read more on Kivora Blog

Get started →