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.