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
getInitialProps
runs during server‑side rendering.- Because I foolishly invoked
signIn
insiderender()
, that network call also happened on the server. next/router
relies on the browser’swindow
, 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 code | Fix in new code |
---|---|
Mutation ran during SSR | Wrapped in useEffect → runs only after first client render |
Redirect used Router.push on server | Switched to useRouter() inside Apollo’s onCompleted callback |
Verbose class component | Concise 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 This | Why It’s Useful |
---|---|---|
1 | Throttle re‑trials — If the network is down, retry signIn every 5 s (max three attempts). | Teaches exponential back‑off & failure‑tolerant UX. |
2 | Dynamic redirect — Read ?next=/dashboard from the URL and route there instead of /user . | Mirrors real‑world OAuth “return‑to” patterns. |
3 | SSR 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. |
4 | Add 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.