Skip to main content
Back to Blog
Tutorials
3 min read
January 12, 2025

How to Implement GraphQL Subscriptions With urql in Next.js

Set up GraphQL subscriptions with urql in Next.js for real-time data updates using WebSocket transport and server-sent events.

Ryel Banfield

Founder & Lead Developer

GraphQL subscriptions push real-time updates to the client. Here is how to set them up with urql.

Install Dependencies

pnpm add urql graphql graphql-ws ws @urql/exchange-graphcache

GraphQL Schema With Subscriptions

# schema.graphql
type Message {
  id: ID!
  content: String!
  author: String!
  createdAt: String!
}

type Query {
  messages(channelId: ID!): [Message!]!
}

type Mutation {
  sendMessage(channelId: ID!, content: String!, author: String!): Message!
}

type Subscription {
  newMessage(channelId: ID!): Message!
  typingIndicator(channelId: ID!): TypingEvent!
}

type TypingEvent {
  userId: String!
  username: String!
  isTyping: Boolean!
}

Server-Side WebSocket Handler

// lib/graphql/pubsub.ts
type Listener<T = unknown> = (data: T) => void;

class PubSub {
  private topics = new Map<string, Set<Listener>>();

  subscribe<T>(topic: string, listener: Listener<T>): () => void {
    if (!this.topics.has(topic)) {
      this.topics.set(topic, new Set());
    }
    const listeners = this.topics.get(topic)!;
    listeners.add(listener as Listener);

    return () => {
      listeners.delete(listener as Listener);
      if (listeners.size === 0) {
        this.topics.delete(topic);
      }
    };
  }

  publish<T>(topic: string, data: T): void {
    const listeners = this.topics.get(topic);
    if (listeners) {
      for (const listener of listeners) {
        listener(data);
      }
    }
  }
}

export const pubsub = new PubSub();

urql Client Configuration

// lib/graphql/client.ts
"use client";

import {
  Client,
  fetchExchange,
  subscriptionExchange,
  cacheExchange,
} from "urql";
import { createClient as createWSClient } from "graphql-ws";

let wsClient: ReturnType<typeof createWSClient> | null = null;

function getWSClient() {
  if (typeof window === "undefined") return null;

  if (!wsClient) {
    wsClient = createWSClient({
      url: process.env.NEXT_PUBLIC_WS_URL ?? "ws://localhost:3001/graphql",
      connectionParams: () => {
        const token = document.cookie
          .split("; ")
          .find((c) => c.startsWith("session="))
          ?.split("=")[1];
        return { authorization: token ? `Bearer ${token}` : "" };
      },
      retryAttempts: 5,
      shouldRetry: () => true,
      on: {
        connected: () => console.log("WS connected"),
        closed: () => console.log("WS closed"),
        error: (err) => console.error("WS error:", err),
      },
    });
  }

  return wsClient;
}

export function createUrqlClient() {
  const exchanges = [cacheExchange, fetchExchange];

  const ws = getWSClient();
  if (ws) {
    exchanges.push(
      subscriptionExchange({
        forwardSubscription: (request) => ({
          subscribe: (sink) => ({
            unsubscribe: ws.subscribe(
              { query: request.query, variables: request.variables },
              sink,
            ),
          }),
        }),
      }),
    );
  }

  return new Client({
    url: "/api/graphql",
    exchanges,
  });
}

urql Provider

// components/providers/UrqlProvider.tsx
"use client";

import { useMemo } from "react";
import { Provider } from "urql";
import { createUrqlClient } from "@/lib/graphql/client";

export function UrqlProvider({ children }: { children: React.ReactNode }) {
  const client = useMemo(() => createUrqlClient(), []);
  return <Provider value={client}>{children}</Provider>;
}

Subscription Hook Usage

// components/ChatMessages.tsx
"use client";

import { useQuery, useSubscription, useMutation } from "urql";
import { useState, useEffect, useRef } from "react";

const MESSAGES_QUERY = `
  query Messages($channelId: ID!) {
    messages(channelId: $channelId) {
      id
      content
      author
      createdAt
    }
  }
`;

const NEW_MESSAGE_SUB = `
  subscription NewMessage($channelId: ID!) {
    newMessage(channelId: $channelId) {
      id
      content
      author
      createdAt
    }
  }
`;

const SEND_MESSAGE_MUT = `
  mutation SendMessage($channelId: ID!, $content: String!, $author: String!) {
    sendMessage(channelId: $channelId, content: $content, author: $author) {
      id
      content
      author
      createdAt
    }
  }
`;

const TYPING_SUB = `
  subscription Typing($channelId: ID!) {
    typingIndicator(channelId: $channelId) {
      userId
      username
      isTyping
    }
  }
`;

interface Message {
  id: string;
  content: string;
  author: string;
  createdAt: string;
}

export function ChatMessages({ channelId }: { channelId: string }) {
  const [messages, setMessages] = useState<Message[]>([]);
  const [input, setInput] = useState("");
  const [typingUsers, setTypingUsers] = useState<Map<string, string>>(new Map());
  const bottomRef = useRef<HTMLDivElement>(null);

  // Initial query
  const [queryResult] = useQuery({
    query: MESSAGES_QUERY,
    variables: { channelId },
  });

  useEffect(() => {
    if (queryResult.data?.messages) {
      setMessages(queryResult.data.messages);
    }
  }, [queryResult.data]);

  // Subscribe to new messages
  useSubscription(
    { query: NEW_MESSAGE_SUB, variables: { channelId } },
    (prev, data) => {
      if (data.newMessage) {
        setMessages((msgs) => [...msgs, data.newMessage]);
        bottomRef.current?.scrollIntoView({ behavior: "smooth" });
      }
      return data;
    },
  );

  // Subscribe to typing indicators
  useSubscription(
    { query: TYPING_SUB, variables: { channelId } },
    (prev, data) => {
      if (data.typingIndicator) {
        const { userId, username, isTyping } = data.typingIndicator;
        setTypingUsers((prev) => {
          const next = new Map(prev);
          if (isTyping) {
            next.set(userId, username);
          } else {
            next.delete(userId);
          }
          return next;
        });
      }
      return data;
    },
  );

  const [, sendMessage] = useMutation(SEND_MESSAGE_MUT);

  async function handleSend(e: React.FormEvent) {
    e.preventDefault();
    if (!input.trim()) return;

    await sendMessage({
      channelId,
      content: input.trim(),
      author: "Current User",
    });
    setInput("");
  }

  return (
    <div className="flex flex-col h-[500px] border rounded-lg">
      {/* Messages list */}
      <div className="flex-1 overflow-y-auto p-4 space-y-3">
        {messages.map((msg) => (
          <div key={msg.id} className="flex flex-col">
            <div className="flex items-baseline gap-2">
              <span className="font-semibold text-sm">{msg.author}</span>
              <span className="text-xs text-muted-foreground">
                {new Date(msg.createdAt).toLocaleTimeString()}
              </span>
            </div>
            <p className="text-sm">{msg.content}</p>
          </div>
        ))}
        <div ref={bottomRef} />
      </div>

      {/* Typing indicator */}
      {typingUsers.size > 0 && (
        <div className="px-4 py-1 text-xs text-muted-foreground">
          {Array.from(typingUsers.values()).join(", ")}{" "}
          {typingUsers.size === 1 ? "is" : "are"} typing...
        </div>
      )}

      {/* Input */}
      <form onSubmit={handleSend} className="flex gap-2 p-3 border-t">
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="Type a message..."
          className="flex-1 px-3 py-2 border rounded-md text-sm"
        />
        <button
          type="submit"
          disabled={!input.trim()}
          className="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm disabled:opacity-50"
        >
          Send
        </button>
      </form>
    </div>
  );
}

Need Real-Time Features?

We build real-time applications with GraphQL subscriptions and WebSockets. Contact us to get started.

GraphQLsubscriptionsurqlWebSocketreal-timetutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles