GraphQL Frontend Guide: Next.js & Apollo Best Practices

GraphQL Frontend Tutorial – Building PhotoStock with Next.js & Apollo Client

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

🌟 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 and useMutation
  • 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/ or app/ 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:

$ git clone https://github.com/m-saad-siddique/graphql-backend.git
$ cd graphql-backend

Start the backend with Docker:

$ docker-compose up

Access the backend:

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 a src/ folder for better organization
npx create-next-app@latest photostock-frontend --tailwind --app --src-dir
cd photostock-frontend
Tip: You can use 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.js
  • apollo-upload-client: Adds file upload support to Apollo
  • graphql: The core GraphQL library (required for Apollo)
npm install @apollo/client apollo-upload-client graphql
Tip: If you see peer dependency warnings, you can usually ignore them for these packages.

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 components
  • src/apollo/: (You’ll create this soon) Apollo Client setup
  • package.json: Lists your dependencies and scripts
  • tailwind.config.js: Tailwind CSS configuration
Best Practice: Keep your code organized in 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, and signup 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):

$ cd graphql-backend
$ docker-compose up

Start the frontend (in another terminal):

$ cd photostock-frontend
$ npm run dev

Test the integration:

⚠️ 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

GraphQL – A query language and runtime for APIs that allows clients to request exactly the data they need.
Schema – The contract between client and server that defines what queries and mutations are available.
type Photo {
  id: ID!
  title: String!
  imageUrl: String!
  user: User!
}
Type – A definition of an object with specific fields and their data types.
type User {
  id: ID!
  email: String!
  name: String
}
Field – A property of a type that can be queried.
photos {
  id      # This is a field
  title   # This is a field
}
Scalar – Basic data types like String, Int, Float, Boolean, ID.
type Photo {
  id: ID!           # Scalar
  title: String!    # Scalar
  likesCount: Int   # Scalar
}
Object Type – A type that contains other fields (non-scalar).
type Photo {
  id: ID!
  user: User!  # Object type (not scalar)
}
Interface – A shared set of fields that multiple types can implement.
interface Media {
  id: ID!
  title: String!
}

type Photo implements Media {
  id: ID!
  title: String!
  imageUrl: String!
}
Union – A type that can be one of several types.
union SearchResult = Photo | User | Comment
Enum – A type with a specific set of allowed values.
enum PhotoCategory {
  NATURE
  URBAN
  PORTRAIT
}
Input Type – A type used for passing data to mutations.
input AddPhotoInput {
  title: String!
  image: Upload!
}
Directive – Instructions that modify the execution of queries.
query GetPhotos {
  photos @include(if: $showPhotos) {
    id
    title
  }
}

Query Language Terms

Query – A read-only operation to fetch data.
query GetPhotos {
  photos {
    id
    title
  }
}
Mutation – An operation that modifies data.
mutation AddPhoto($input: AddPhotoInput!) {
  addPhoto(input: $input) {
    id
    title
  }
}
Subscription – A real-time operation that listens for events.
subscription OnPhotoAdded {
  photoAdded {
    id
    title
  }
}
Fragment – A reusable piece of query logic.
fragment PhotoFields on Photo {
  id
  title
  imageUrl
}

query GetPhotos {
  photos {
    ...PhotoFields
  }
}
Variable – A dynamic value passed to queries.
query GetPhoto($id: ID!) {
  photo(id: $id) {
    id
    title
  }
}
Argument – A parameter passed to a field.
photos(limit: 10, offset: 0) {
  id
  title
}
Alias – A custom name for a field result.
query {
  recentPhotos: photos(limit: 5) {
    id
    title
  }
  popularPhotos: photos(sortBy: "likes") {
    id
    title
  }
}

Apollo Client Terms

Apollo Client – A comprehensive state management library for GraphQL.
import { ApolloClient, InMemoryCache } from "@apollo/client";
ApolloProvider – React context provider that makes Apollo Client available to components.
import { ApolloProvider } from "@apollo/client";
useQuery – React hook for executing GraphQL queries.
const { data, loading, error } = useQuery(GET_PHOTOS);
useMutation – React hook for executing GraphQL mutations.
const [addPhoto, { loading, error }] = useMutation(ADD_PHOTO);
useSubscription – React hook for GraphQL subscriptions.
const { data } = useSubscription(PHOTO_ADDED);
useLazyQuery – React hook for queries that don’t execute immediately.
const [getPhotos, { data }] = useLazyQuery(GET_PHOTOS);
InMemoryCache – Apollo Client’s default cache implementation.
const cache = new InMemoryCache();
Cache Normalization – Process of storing objects by their IDs for efficient access.
// Raw: { photos: [{ id: "1", user: { id: "1", name: "John" } }] }
// Normalized: { "Photo:1": { id: "1", user: "User:1" }, "User:1": { id: "1", name: "John" } }
Type Policy – Configuration for how specific types are stored in cache.
const cache = new InMemoryCache({
  typePolicies: {
    Photo: {
      keyFields: ["id"]
    }
  }
});
Field Policy – Custom logic for reading or writing specific fields.
fields: {
  displayTitle: {
    read(existing, { readField }) {
      const title = readField("title");
      return title ? title.toUpperCase() : "";
    }
  }
}
Optimistic Response – Immediate UI update before server response.
optimisticResponse: {
  addPhoto: {
    id: "temp-id",
    title: "New Photo",
    __typename: "Photo"
  }
}
Update Function – Custom logic for updating cache after mutations.
update: (cache, { data }) => {
  cache.modify({
    fields: {
      photos: (existing = []) => [...existing, data.addPhoto]
    }
  });
}
Refetch Queries – Automatically re-execute queries after mutations.
refetchQueries: [{ query: GET_PHOTOS }]
Fetch Policy – Strategy for when to fetch data from cache vs network.
fetchPolicy: "cache-first" // cache-and-network, network-only, cache-only, no-cache
Error Policy – How to handle GraphQL errors.
errorPolicy: "all" // ignore, none
Context – Additional information sent with requests (headers, etc.).
context: { headers: { Authorization: `Bearer ${token}` } }

Network & HTTP Terms

HTTP POST – The standard method for GraphQL requests.
// GraphQL requests are always POST to /graphql
fetch("/graphql", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ query, variables })
});
Operation Name – Optional name for queries/mutations for debugging.
query GetPhotos {
  photos { id title }
}
Introspection – Querying the schema to discover available types and operations.
query IntrospectionQuery {
  __schema {
    types { name fields { name type { name } } }
  }
}
GraphiQL – Interactive GraphQL IDE for testing queries.
http://localhost:3000/graphiql

Error & Validation Terms

GraphQL Error – Error returned in the response.
{
  "errors": [
    {
      "message": "Photo not found",
      "locations": [{ "line": 2, "column": 3 }],
      "path": ["photo"]
    }
  ]
}
Network Error – HTTP-level error (timeout, connection failed).
if (error.networkError) {
  console.log("Network error:", error.networkError);
}
Validation Error – Error when query doesn’t match schema.
// Error: Field "invalidField" doesn't exist on type "Photo"
Authentication Error – Error when user is not authenticated.
if (error.graphQLErrors?.some(e => e.extensions?.code === 'UNAUTHENTICATED')) {
  // Redirect to login
}
Authorization Error – Error when user doesn’t have permission.
if (error.graphQLErrors?.some(e => e.extensions?.code === 'FORBIDDEN')) {
  // Show access denied
}

Performance Terms

N+1 Problem – Multiple database queries for related data.
# Bad: Could cause N+1
query GetPhotos {
  photos {
    id
    user {  # Separate query for each photo
      id
      name
    }
  }
}
DataLoader – Tool to batch and cache database queries.
// Backend solution to prevent N+1
const userLoader = new DataLoader(async (userIds) => {
  return await User.findByIds(userIds);
});
Connection – Pagination pattern with edges and nodes.
type PhotoConnection {
  edges: [PhotoEdge!]!
  pageInfo: PageInfo!
}

type PhotoEdge {
  node: Photo!
  cursor: String!
}
Cursor – Opaque string for pagination.
query GetPhotos($cursor: String) {
  photos(first: 10, after: $cursor) {
    edges {
      cursor
      node { id title }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}
Offset Pagination – Pagination using skip/limit.
query GetPhotos($offset: Int, $limit: Int) {
  photos(offset: $offset, limit: $limit) {
    id
    title
  }
}

File Upload Terms

Upload Scalar – Special scalar type for file uploads.
scalar Upload

type Mutation {
  uploadPhoto(file: Upload!): Photo!
}
Multipart Form Data – HTTP format for file uploads.
// Apollo Upload Client handles this automatically
const [uploadPhoto] = useMutation(UPLOAD_PHOTO);

Real-time Terms

WebSocket – Protocol for real-time communication.
const wsLink = new WebSocketLink({
  uri: "ws://localhost:3000/graphql"
});
Subscription – Real-time data stream.
subscription OnPhotoLiked($photoId: ID!) {
  photoLiked(photoId: $photoId) {
    id
    likesCount
  }
}
Pub/Sub – Publish/Subscribe pattern for real-time updates.
// Backend: pubsub.publish('PHOTO_LIKED', { photoId, likesCount });
// Frontend: subscription listens for 'PHOTO_LIKED' events

Development Terms

Schema Stitching – Combining multiple GraphQL schemas.
// Merging user service + photo service schemas
Federation – Distributed GraphQL architecture.
// Multiple services, one unified GraphQL API
Code Generation – Automatically generating TypeScript types from schema.
npx graphql-codegen --config codegen.yml
GraphQL Playground – Alternative to GraphiQL for testing.
http://localhost:3000/graphql-playground
Apollo Studio – Cloud platform for GraphQL development.
// Schema registry, metrics, and debugging tools

🚀 Step 9: Next Steps & Resources

🎉 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

12 thoughts on “GraphQL Frontend Guide: Next.js & Apollo Best Practices”

Comments are closed.

Scroll to Top