Beginner’s Guide: Building a GraphQL Frontend with Next.js & Apollo Client
Welcome! This tutorial will help you build a modern photo-sharing app frontend using GraphQL, Apollo Client, and Next.js. No prior experience with GraphQL is required—everything is explained step by step.
Frontend Repository: https://github.com/m-saad-siddique/photostock-frontend
📋 Table of Contents
- 🌟 What is GraphQL?
- 🌟 What is Apollo Client?
- 🌟 What is Next.js?
- 🔧 Backend Setup for Testing
- 🚀 Step 1: Project Setup
- 🚀 Step 2: Apollo Client Setup
- 🚀 Step 3: Add ApolloProvider
- 🚀 Step 4: Authentication
- 🚀 Step 5: Fetching Data (Queries)
- 🚀 Step 6: Mutating Data
- 🚀 Step 7: UI and Styling
- 🚀 Step 8: Testing with Backend
- 🚀 Step 9: Next Steps & Resources
- 📚 GraphQL Frontend Vocabulary & Terminology
- 🎉 Congratulations!
🌟 What is GraphQL?
GraphQL is a query language for APIs. Unlike REST, where you have many endpoints, GraphQL lets you ask for exactly the data you need from a single endpoint. This makes your app faster and your code cleaner.
Example:
- REST:
/api/photos
,/api/users
,/api/photos/123/comments
- GraphQL: One endpoint
/graphql
where you can ask for photos, users, and comments in one request.
🌟 What is Apollo Client?
Apollo Client is a powerful library that connects your React (or Next.js) app to a GraphQL server. It makes working with GraphQL easy and efficient by handling data fetching, caching, and UI updates for you.
- Fetches data from your GraphQL API using queries and mutations
- Caches data locally for fast, responsive UIs
- Manages loading and error states automatically
- Integrates deeply with React using hooks like
useQuery
anduseMutation
- Supports file uploads, authentication, and real-time updates
import { ApolloClient, InMemoryCache } from "@apollo/client";
import { createUploadLink } from "apollo-upload-client";
const client = new ApolloClient({
link: createUploadLink({ uri: "http://localhost:3000/graphql" }),
cache: new InMemoryCache(),
});
ApolloClient
is the main class for connecting to GraphQL.InMemoryCache
stores your data locally for fast access.createUploadLink
lets you upload files (like photos) via GraphQL.
🌟 What is Next.js?
Next.js is a powerful React framework for building fast, modern web applications. It extends React with features like server-side rendering (SSR), static site generation (SSG), API routes, and more—making it a popular choice for both startups and large companies.
- Server-Side Rendering (SSR): Render pages on the server for better SEO and faster initial load.
- Static Site Generation (SSG): Pre-render pages at build time for blazing-fast performance.
- API Routes: Build backend endpoints directly in your frontend project.
- File-based Routing: Create pages by adding files to the
pages/
orapp/
directory. - Built-in CSS & Sass support: Style your app easily.
- Image Optimization: Automatically optimize images for performance.
- Great Developer Experience: Fast refresh, TypeScript support, and more.
✅ Pros:
- Excellent performance out of the box (SSR/SSG)
- SEO-friendly (great for marketing and content sites)
- Easy routing and code splitting
- API routes for full-stack development
- Strong community and Vercel support
⚠️ Cons:
- Build times can be long for very large static sites
- Some learning curve if new to SSR/SSG concepts
- Occasional gotchas with server/client code separation
🔧 Backend Setup for Testing
Before we start building the frontend, you’ll need a GraphQL backend to test against. We’ll use the GraphQL Backend repository which provides a complete Rails-based GraphQL API.
Clone the backend repository:
$ cd graphql-backend
Start the backend with Docker:
Access the backend:
- GraphQL Endpoint: http://localhost:3000/graphql
- GraphiQL Interface: http://localhost:3000/graphiql (for testing queries)
Backend Features:
- User authentication with JWT tokens
- Photo uploads with Active Storage
- Like/unlike functionality
- Comments system
- Search and filtering
- Comprehensive documentation in
GRAPHQL_API_TUTORIAL.md
💡 Testing Your Frontend
Once the backend is running, your frontend will be able to:
- Connect to
http://localhost:3000/graphql
- Test authentication flows
- Upload and display photos
- Like/unlike photos
- Search through photos
🚀 Step 1: Project Setup
This step will guide you through creating a new Next.js project, installing all required dependencies, and understanding the project structure. Follow each step carefully—even if you’re new to React or Next.js, you’ll be up and running in minutes!
Create a new Next.js app:
We’ll use the official create-next-app
tool to scaffold your project. The flags below help you start with best practices:
--tailwind
: Adds Tailwind CSS for easy, modern styling--app
: Uses the new Next.js App Router (recommended for new projects)--src-dir
: Puts your code in asrc/
folder for better organization
npx create-next-app@latest photostock-frontend --tailwind --app --src-dir
cd photostock-frontend
yarn
or pnpm
instead of npm
if you prefer.Install GraphQL and Apollo Client:
Install the libraries you’ll need for GraphQL and file uploads:
@apollo/client
: The official Apollo Client for React/Next.jsapollo-upload-client
: Adds file upload support to Apollographql
: The core GraphQL library (required for Apollo)
npm install @apollo/client apollo-upload-client graphql
Open your project in VS Code (or your favorite editor):
code .
This opens the project folder in VS Code. You can use any editor you like, but VS Code is highly recommended for its extensions and built-in terminal.
Explore the project structure:
src/
: All your app code lives here (pages, components, styles, etc.)src/app/
: The main entry point for your app (App Router)src/components/
: Place for your React componentssrc/apollo/
: (You’ll create this soon) Apollo Client setuppackage.json
: Lists your dependencies and scriptstailwind.config.js
: Tailwind CSS configuration
src/
and use folders for features (e.g., components/
, graphql/
, context/
).Start the development server:
npm run dev
Visit http://localhost:3000 in your browser. You should see the default Next.js welcome page. You’re ready to start building!
🚀 Step 2: Apollo Client Setup
Apollo Client connects your app to the GraphQL backend.
Create a file: src/apollo/client.js
import { ApolloClient, InMemoryCache } from "@apollo/client";
import { createUploadLink } from "apollo-upload-client";
const client = new ApolloClient({
link: createUploadLink({ uri: "http://localhost:3000/graphql" }),
cache: new InMemoryCache(),
});
export default client;
What’s happening?
ApolloClient
is the main class for connecting to GraphQL.InMemoryCache
stores your data locally for fast access.createUploadLink
lets you upload files (like photos) via GraphQL.- The URI points to the Rails backend running on port 3000.
🚀 Step 3: Add ApolloProvider
To use Apollo everywhere, wrap your app in an ApolloProvider
.
Create: src/components/ApolloWrapper.js
"use client";
import { ApolloProvider } from "@apollo/client";
import client from "../apollo/client";
export default function ApolloWrapper({ children }) {
return {children} ;
}
Update your root layout: src/app/layout.js
"use client";
import "./globals.css";
import ApolloWrapper from "../components/ApolloWrapper";
import { AuthProvider } from '../contexts/AuthContext';
export default function RootLayout({ children }) {
return (
{children}
);
}
🚀 Step 4: Authentication (Login & Signup)
We’ll use React Context to manage user authentication.
Create: src/context/AuthContext.js
"use client";
import { createContext, useContext, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { gql, useLazyQuery, useMutation } from "@apollo/client";
// GraphQL queries and mutations (matching the backend schema)
const LOGIN_MUTATION = gql`
mutation SignIn($input: SignInInput!) {
signIn(input: $input) {
token
user { id email }
}
}
`;
const SIGNUP_MUTATION = gql`
mutation SignUp($input: SignUpInput!) {
signUp(input: $input) { id email }
}
`;
const ME_QUERY = gql`
query { me { id email } }
`;
const AuthContext = createContext();
export function AuthProvider({ children }) {
const router = useRouter();
const [user, setUser] = useState(null);
const [token, setToken] = useState(null);
const [signInMutation] = useMutation(LOGIN_MUTATION);
const [signUpMutation] = useMutation(SIGNUP_MUTATION);
const [loadUser] = useLazyQuery(ME_QUERY, {
fetchPolicy: "network-only",
onCompleted: (data) => setUser(data.me),
onError: () => logout(),
});
useEffect(() => {
const storedToken = typeof window !== "undefined" ? localStorage.getItem("token") : null;
if (storedToken) {
setToken(storedToken);
loadUser({ context: { headers: { Authorization: `Bearer ${storedToken}` } } });
}
}, []);
const login = async (email, password) => {
const res = await signInMutation({ variables: { input: { email, password } } });
const t = res.data.signIn.token;
setToken(t);
localStorage.setItem("token", t);
setUser(res.data.signIn.user);
router.push("/dashboard");
};
const signup = async (email, password) => {
await signUpMutation({ variables: { input: { email, password } } });
await login(email, password);
};
const logout = () => {
setToken(null);
setUser(null);
localStorage.removeItem("token");
router.push("/login");
};
return (
{children}
);
}
export const useAuth = () => useContext(AuthContext);
What’s happening?
- We store the user and token in React state and localStorage.
- We use GraphQL mutations to log in and sign up.
- The context provides
login
,logout
, andsignup
functions to the whole app. - The mutations match the backend schema from the Rails GraphQL API.
Wrap your app with AuthProvider in layout.js:
{`import { AuthProvider } from './path/to/AuthProvider';
export default function RootLayout({ children }) {
return (
{children}
);
}`}
🚀 Step 5: Fetching Data (Queries)
Let’s show a list of photos from the backend.
In src/app/page.js:
{
if (token) {
loadPhotos({
variables: { limit, offset, titleContains: search },
context: { headers: { Authorization: `Bearer ${token}` } },
});
}
}, [token, offset, search]);
// ... render your UI here ...
}
What’s happening?
- We use
useLazyQuery
to fetch photos only when the user is authenticated. - We pass the token in the headers for authorization.
- We can search and paginate photos.
- The query structure matches the backend schema.
🚀 Step 6: Mutating Data (Like, Upload)
Liking a photo:
const TOGGLE_LIKE = gql`
mutation LikePhoto($input: LikePhotoInput!) {
likePhoto(input: $input) { liked }
}
`;
const [likePhoto] = useMutation(TOGGLE_LIKE, {
context: { headers: { Authorization: `Bearer ${token}` } },
onCompleted: (data) => setLiked(data.likePhoto.liked),
onError: (err) => alert(err.message),
});
const handleLike = async () => {
if (!user) return;
await likePhoto({ variables: { input: { photoId: photo.id } } });
};
Uploading a photo:
const UPLOAD_PHOTO = gql`
mutation UploadPhoto($input: UploadPhotoInput!) {
uploadPhoto(input: $input) { id title imageUrl }
}
`;
const [uploadPhoto, { loading, error }] = useMutation(UPLOAD_PHOTO);
const handleSubmit = async (e) => {
e.preventDefault();
if (!image) return alert("Please select an image");
await uploadPhoto({
variables: { input: { title, image } },
context: { headers: { Authorization: `Bearer ${token}` } },
});
};
🚀 Step 7: UI and Styling
- Use Tailwind CSS for easy, responsive design.
- Use React components for cards, modals, and forms.
- Show loading and error states for a better user experience.
🚀 Step 8: Testing with the Backend
Running Both Applications
Start the backend (in one terminal):
$ docker-compose up
Start the frontend (in another terminal):
$ npm run dev
Test the integration:
- Frontend: http://localhost:3001
- Backend GraphQL: http://localhost:3000/graphql
- GraphiQL: http://localhost:3000/graphiql
⚠️ Common Issues and Solutions
- CORS errors: The backend should handle CORS for localhost:3001
- Port conflicts: Make sure the backend runs on port 3000 and frontend on 3001
- Authentication: Use the GraphiQL interface to test mutations first
📚 GraphQL Frontend Vocabulary & Terminology
Core GraphQL Terms
type Photo {
id: ID!
title: String!
imageUrl: String!
user: User!
}
type User {
id: ID!
email: String!
name: String
}
photos {
id # This is a field
title # This is a field
}
String
, Int
, Float
, Boolean
, ID
.type Photo {
id: ID! # Scalar
title: String! # Scalar
likesCount: Int # Scalar
}
type Photo {
id: ID!
user: User! # Object type (not scalar)
}
interface Media {
id: ID!
title: String!
}
type Photo implements Media {
id: ID!
title: String!
imageUrl: String!
}
union SearchResult = Photo | User | Comment
enum PhotoCategory {
NATURE
URBAN
PORTRAIT
}
input AddPhotoInput {
title: String!
image: Upload!
}
query GetPhotos {
photos @include(if: $showPhotos) {
id
title
}
}
Query Language Terms
query GetPhotos {
photos {
id
title
}
}
mutation AddPhoto($input: AddPhotoInput!) {
addPhoto(input: $input) {
id
title
}
}
subscription OnPhotoAdded {
photoAdded {
id
title
}
}
fragment PhotoFields on Photo {
id
title
imageUrl
}
query GetPhotos {
photos {
...PhotoFields
}
}
query GetPhoto($id: ID!) {
photo(id: $id) {
id
title
}
}
photos(limit: 10, offset: 0) {
id
title
}
query {
recentPhotos: photos(limit: 5) {
id
title
}
popularPhotos: photos(sortBy: "likes") {
id
title
}
}
Apollo Client Terms
import { ApolloClient, InMemoryCache } from "@apollo/client";
import { ApolloProvider } from "@apollo/client";
const { data, loading, error } = useQuery(GET_PHOTOS);
const [addPhoto, { loading, error }] = useMutation(ADD_PHOTO);
const { data } = useSubscription(PHOTO_ADDED);
const [getPhotos, { data }] = useLazyQuery(GET_PHOTOS);
const cache = new InMemoryCache();
// Raw: { photos: [{ id: "1", user: { id: "1", name: "John" } }] }
// Normalized: { "Photo:1": { id: "1", user: "User:1" }, "User:1": { id: "1", name: "John" } }
const cache = new InMemoryCache({
typePolicies: {
Photo: {
keyFields: ["id"]
}
}
});
fields: {
displayTitle: {
read(existing, { readField }) {
const title = readField("title");
return title ? title.toUpperCase() : "";
}
}
}
optimisticResponse: {
addPhoto: {
id: "temp-id",
title: "New Photo",
__typename: "Photo"
}
}
update: (cache, { data }) => {
cache.modify({
fields: {
photos: (existing = []) => [...existing, data.addPhoto]
}
});
}
refetchQueries: [{ query: GET_PHOTOS }]
fetchPolicy: "cache-first" // cache-and-network, network-only, cache-only, no-cache
errorPolicy: "all" // ignore, none
context: { headers: { Authorization: `Bearer ${token}` } }
Network & HTTP Terms
// GraphQL requests are always POST to /graphql
fetch("/graphql", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query, variables })
});
query GetPhotos {
photos { id title }
}
query IntrospectionQuery {
__schema {
types { name fields { name type { name } } }
}
}
http://localhost:3000/graphiql
Error & Validation Terms
{
"errors": [
{
"message": "Photo not found",
"locations": [{ "line": 2, "column": 3 }],
"path": ["photo"]
}
]
}
if (error.networkError) {
console.log("Network error:", error.networkError);
}
// Error: Field "invalidField" doesn't exist on type "Photo"
if (error.graphQLErrors?.some(e => e.extensions?.code === 'UNAUTHENTICATED')) {
// Redirect to login
}
if (error.graphQLErrors?.some(e => e.extensions?.code === 'FORBIDDEN')) {
// Show access denied
}
Performance Terms
# Bad: Could cause N+1
query GetPhotos {
photos {
id
user { # Separate query for each photo
id
name
}
}
}
// Backend solution to prevent N+1
const userLoader = new DataLoader(async (userIds) => {
return await User.findByIds(userIds);
});
type PhotoConnection {
edges: [PhotoEdge!]!
pageInfo: PageInfo!
}
type PhotoEdge {
node: Photo!
cursor: String!
}
query GetPhotos($cursor: String) {
photos(first: 10, after: $cursor) {
edges {
cursor
node { id title }
}
pageInfo {
hasNextPage
endCursor
}
}
}
query GetPhotos($offset: Int, $limit: Int) {
photos(offset: $offset, limit: $limit) {
id
title
}
}
File Upload Terms
scalar Upload
type Mutation {
uploadPhoto(file: Upload!): Photo!
}
// Apollo Upload Client handles this automatically
const [uploadPhoto] = useMutation(UPLOAD_PHOTO);
Real-time Terms
const wsLink = new WebSocketLink({
uri: "ws://localhost:3000/graphql"
});
subscription OnPhotoLiked($photoId: ID!) {
photoLiked(photoId: $photoId) {
id
likesCount
}
}
// Backend: pubsub.publish('PHOTO_LIKED', { photoId, likesCount });
// Frontend: subscription listens for 'PHOTO_LIKED' events
Development Terms
// Merging user service + photo service schemas
// Multiple services, one unified GraphQL API
npx graphql-codegen --config codegen.yml
http://localhost:3000/graphql-playground
// Schema registry, metrics, and debugging tools
🚀 Step 9: Next Steps & Resources
- Try adding new features (comments, user profiles, etc.)
- Learn more about GraphQL and Apollo Client:
- Explore the Backend Tutorial for deeper GraphQL understanding
🎉 Congratulations!
You’ve built a modern, full-featured GraphQL frontend that connects to a Rails backend. You now know how to:
- Set up Apollo Client in Next.js
- Connect to a GraphQL backend
- Authenticate users with GraphQL
- Query and mutate data
- Upload files
- Build a beautiful, responsive UI
Keep experimenting and happy coding! 🚀
Learn more about Rails setup
Learn more about GraphQL Tutorial setup
Learn more about React setup
dute7b
Promote our brand, reap the rewards—apply to our affiliate program today! https://shorturl.fm/UVt61
Drive sales, collect commissions—join our affiliate team! https://shorturl.fm/yoTT4
Share our products, earn up to 40% per sale—apply today! https://shorturl.fm/yIJly
Refer friends, collect commissions—sign up now! https://shorturl.fm/nMUcn
Apply now and unlock exclusive affiliate rewards! https://shorturl.fm/wL3Vz
Partner with us and enjoy high payouts—apply now! https://shorturl.fm/lZr06
Partner with us and enjoy high payouts—apply now! https://shorturl.fm/m7Lgs
Drive sales and watch your affiliate earnings soar! https://shorturl.fm/ckmN7
Earn up to 40% commission per sale—join our affiliate program now! https://shorturl.fm/Cf9UA
Share our link, earn real money—signup for our affiliate program! https://shorturl.fm/gC4YI
Turn referrals into revenue—sign up for our affiliate program today! https://shorturl.fm/A942E