How I Solve a Next.js Redirect Inside a GraphQL Mutation

I’m kicking off the engagement by plunging straight using next.js into the client’s business problem, running a discovery sprint that mixes white‑board workshops with hands‑on code spikes so I can translate fuzzy ideas into a concrete, testable backlog.

In practice, that means interviewing stakeholders to map user journeys, auditing their current stack for hidden bottlenecks, drafting a domain model that aligns with real‑world workflows, and sketching out a modular system architecture micro‑services where speed matters, a monolith where cohesion wins. While the product owner refines acceptance criteria,

I wire up a skeleton repo with ESLint, Prettier, Husky hooks, and a CI/CD pipeline in GitHub Actions so every push is linted, tested, containerized, and hoisted to a staging cluster. Parallel tracks run: UX designers craft high‑fidelity Figma mocks, QA engineers define Gherkin scenarios, and I pair‑program with another senior dev to spike the riskiest integrations OAuth2 with the client’s SSO, a GraphQL gateway in front of legacy REST, and event‑driven messaging over Kafka. By the end of the first week we aren’t just talking about features; we have a living proof of concept, complete with automated tests and a Kanban board packed with bite‑sized, business‑valued stories ready for the first true sprint

(how I broke it, the error I got, the clean‑room fix, and a few mini‑challenges so you can stretch your skills)

The First Attempt Why It Looked Fine but Really Wasn’t

I thought the task was simple: call a GraphQL signIn mutation and, when it finishes, send the user to /user. So I wrote this little class component:

React, { Component } from "react";
import Router from "next/router";
import { Mutation } from "react-apollo";
import { gql } from "apollo-boost";

const SIGN_IN = gql`
mutation signIn($accessToken: String!) {
signIn(accessToken: $accessToken)
}
`;

export default class SignInPage extends Component {
static async getInitialProps({ query: { accessToken } }) {
return { accessToken }; // runs on *both* server and client
}

render() {
const { accessToken } = this.props;

return (
<Mutation mutation={SIGN_IN} ignoreResults>
{signIn => {
signIn({ variables: { accessToken } }) // fires during the *render* pass
.then(() => Router.push("/user")); // ← Router used while SSR is running
return null;
}}
</Mutation>
);
}
}

The Error

: You should only use "next/router" inside the client side of your app.

What Really Happened

  1. getInitialProps runs during server‑side rendering.
  2. Because I foolishly invoked signIn inside render(), that network call also happened on the server.
  3. next/router relies on the browser’s window, which doesn’t exist in Node‑land. Boom.

The Clean‑Room Fix Run Everything After I’m Safely in the Browser

Instead of fighting SSR, I rewrote the page as a tiny functional component that waits until the first client render before doing anything dangerous.

{ useEffect } from "react";
import { useRouter } from "next/router";
import { gql, useMutation } from "@apollo/client";

/* 1 · GraphQL definition */
const SIGN_IN = gql`
mutation signIn($accessToken: String!) {
signIn(accessToken: $accessToken)
}
`;

export default function SignInPage({ accessToken }) {
const router = useRouter();

/* 2 · Hook with an onCompleted redirect */
const [doSignIn] = useMutation(SIGN_IN, {
variables: { accessToken },
onCompleted: () => router.replace("/user"), // client‑only redirect
});

/* 3 · Fire once, after mount */
useEffect(() => {
if (accessToken) doSignIn();
}, [accessToken, doSignIn]);

return null; // no UI needed
}

/* 4 · Still fetch the query string on both sides */
SignInPage.getInitialProps = async ({ query: { accessToken = "" } }) => ({
accessToken,
});
Problem in old codeFix in new code
Mutation ran during SSRWrapped in useEffect → runs only after first client render
Redirect used Router.push on serverSwitched to useRouter() inside Apollo’s onCompleted callback
Verbose class componentConcise functional component + React hooks

Extra Safety Nets I Added

Loading & Error States

[doSignIn, { loading, error }] = useMutation(...);

if (loading) return <p>Signing you in…</p>;
if (error) return <p>Oops: {error.message}</p>;

Persisting the Session

: ({ signIn }) => {
localStorage.setItem("jwt", signIn); // or cookies if you prefer
router.replace("/user");
}

Cancel on Unmount

(() => {
const controller = new AbortController();

doSignIn({ context: { fetchOptions: { signal: controller.signal } } });

return () => controller.abort(); // avoid memory leaks on rapid navigation
}, []);

Stretch Your Skills Mini Challenges

#Try ThisWhy It’s Useful
1Throttle re‑trials — If the network is down, retry signIn every 5 s (max three attempts).Teaches exponential back‑off & failure‑tolerant UX.
2Dynamic redirect — Read ?next=/dashboard from the URL and route there instead of /user.Mirrors real‑world OAuth “return‑to” patterns.
3SSR token pre‑validation — In getInitialProps, hit a public endpoint; if the token is already invalid, render a friendly error page and skip the mutation.Saves a useless round‑trip and speeds up perceived load.
4Add TypeScript — Declare SignInVars & SignInResponse, then call useMutation<SignInResponse, SignInVars>().Catch undefined variables before they ever hit production.

Final Thought

Every “next/router only on the client” panic I’ve triggered boils down to when I run code, not what I run. If something needs window, document, localStorage, or the router, tuck it inside useEffect, componentDidMount, or an Apollo onCompleted callback. Keep the heavy hitters out of server‑side execution and your SSR will keep humming while your users glide seamlessly to the right page.

Related blog posts