@trpc/next
Version:
264 lines (210 loc) • 10.6 kB
Markdown
# Server-Side Rendering
> Prefetch a page's tRPC queries in getServerSideProps with createTRPCOptionsProxy, build the context from the request, and set the status and cache headers
From https://trpc.io/docs/client/nextjs/pages-router/ssr
To render a page's data on the server, prefetch its queries in `getServerSideProps`. Next.js then renders the page with the data in the cache, so it's in the first HTML, and the client doesn't fetch it again.
This page builds on the [Pages Router setup](https://trpc.io/docs/client/nextjs/pages-router/setup).
> [!WARNING] Deprecated
> `ssr: true` and `ssrPrepass` from `/next` are deprecated, along with `createTRPCNext`. They rendered every page over and over until no query was fetching. Nothing does that for you now: prefetch each page's queries in `getServerSideProps` instead. You choose what runs on the server, and pages without `getServerSideProps` stay static.
## Prefetch in `getServerSideProps`
In `getServerSideProps`, make a query client and a tRPC proxy that calls your router directly, with no HTTP request. Prefetch the queries the page needs, and return the cache as `trpcState`. `_app`'s `HydrationBoundary` puts it into the client's cache.
```tsx no-check title="pages/posts/[id].tsx"
import { dehydrate, useQuery } from '/react-query';
import { createTRPCOptionsProxy } from '/tanstack-react-query';
import type {
GetServerSidePropsContext,
InferGetServerSidePropsType,
} from 'next';
import { createContext } from '../../server/context';
import { appRouter } from '../../server/routers/_app';
import { makeQueryClient } from '../../utils/query-client';
import { useTRPC } from '../../utils/trpc';
export async function getServerSideProps(
context: GetServerSidePropsContext<{ id: string }>,
) {
const id = context.params?.id as string;
const queryClient = makeQueryClient();
const trpc = createTRPCOptionsProxy({
router: appRouter,
ctx: await createContext({ headers: context.req.headers }),
queryClient,
});
await queryClient.prefetchQuery(trpc.post.byId.queryOptions({ id }));
return {
props: {
trpcState: dehydrate(queryClient),
id,
},
};
}
export default function PostPage(
props: InferGetServerSidePropsType<typeof getServerSideProps>,
) {
const trpc = useTRPC();
const postQuery = useQuery(trpc.post.byId.queryOptions({ id: props.id }));
switch (postQuery.status) {
case 'pending':
return <p>Loading...</p>;
case 'error':
return <p>Error: {postQuery.error.message}</p>;
case 'success':
return (
<article>
<h1>{postQuery.data.title}</h1>
<p>{postQuery.data.text}</p>
</article>
);
}
}
```
The page reads the query as it would without prefetching, so `status` is `'success'` on the first render. Use the same input in both places: it's part of the query key.
> [!TIP]
> To stop the two inputs from drifting apart, return a **handle** instead of the `id`. With `loadHandles` from `/tanstack-react-query/unstable-handles`, `const [handles] = await loadHandles(trpc, { post: ensureEntry(trpc.post.byId, { id }) })` waits for the data like `prefetchQuery`. The server's cache starts empty on each request, so it also rejects like `fetchQuery` if the query fails. Return `props: { trpcState: dehydrate(queryClient), ...handles }`, and the page reads it with `useQuery(fromHandle(trpc, props.post))`. A handle is plain JSON when its input is. If the input has a `Date` or another value your transformer handles, run the handles through the transformer too, as [Using a data transformer](https://trpc.io/docs/client/nextjs/pages-router/setup#using-a-data-transformer) does for the dehydrated state. See [Prefetching with handles](https://trpc.io/docs/client/tanstack-react-query/handles).
`prefetchQuery` never throws, and the cache only sends queries that succeeded, so a failed query fetches again in the browser. To act on an error on the server, use `fetchQuery`, which returns the data or throws. See [Set the status and cache headers](https://trpc.io/docs/client/nextjs/pages-router/ssr#set-the-status-and-cache-headers).
### Build the context from the request
The proxy calls your procedures in the same process, so there's no URL to set and no headers to forward. Build the context from `context.req` instead, as the API handler does. Sharing one `createContext` that only needs the headers keeps the two the same:
```ts no-check title="server/context.ts"
import type { IncomingHttpHeaders } from 'node:http';
import { getUserFromCookie } from './auth';
export async function createContext(opts: { headers: IncomingHttpHeaders }) {
// verify the session here: headers come from the client, so don't
// trust a user ID that's simply sent in one
const user = await getUserFromCookie(opts.headers.cookie);
return { user };
}
```
```ts no-check title="pages/api/trpc/[trpc].ts"
import * as trpcNext from '/server/adapters/next';
import { createContext } from '../../../server/context';
import { appRouter } from '../../../server/routers/_app';
export default trpcNext.createNextApiHandler({
router: appRouter,
createContext: (opts) => createContext({ headers: opts.req.headers }),
});
```
<details>
<summary>If your API runs on another server</summary>
Pass a tRPC client to `createTRPCOptionsProxy` instead of the router. The request now goes over HTTP, so it needs the API's full URL, and you forward the headers the API needs, such as the cookie:
```ts no-check title="pages/posts/[id].tsx"
import { createTRPCClient, httpBatchLink } from '/client';
import { createTRPCOptionsProxy } from '/tanstack-react-query';
import type { GetServerSidePropsContext } from 'next';
import type { AppRouter } from '../../server/routers/_app';
import { makeQueryClient } from '../../utils/query-client';
export async function getServerSideProps(context: GetServerSidePropsContext) {
const queryClient = makeQueryClient();
const trpc = createTRPCOptionsProxy({
client: createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: 'https://api.example.com/trpc',
headers: () => ({ cookie: context.req.headers.cookie }),
}),
],
}),
queryClient,
});
// prefetch and dehydrate as above
}
```
</details>
## Set the status and cache headers
`getServerSideProps` gets the response as `context.res`, so you set headers on it directly. To send a 404 when a post doesn't exist, use `fetchQuery`, catch the `TRPCError`, and return `notFound`:
```tsx no-check title="pages/posts/[id].tsx"
import { dehydrate } from '/react-query';
import { TRPCError } from '/server';
import { createTRPCOptionsProxy } from '/tanstack-react-query';
import type { GetServerSidePropsContext } from 'next';
import { appRouter } from '../../server/routers/_app';
import { makeQueryClient } from '../../utils/query-client';
export async function getServerSideProps(
context: GetServerSidePropsContext<{ id: string }>,
) {
const id = context.params?.id as string;
const queryClient = makeQueryClient();
const trpc = createTRPCOptionsProxy({
router: appRouter,
ctx: {},
queryClient,
});
try {
await queryClient.fetchQuery(trpc.post.byId.queryOptions({ id }));
} catch (cause) {
if (cause instanceof TRPCError && cause.code === 'NOT_FOUND') {
return { notFound: true };
}
throw cause;
}
// cache the page for 1 day, and revalidate it once a second at most
const ONE_DAY_IN_SECONDS = 60 * 60 * 24;
context.res.setHeader(
'cache-control',
`s-maxage=1, stale-while-revalidate=${ONE_DAY_IN_SECONDS}`,
);
return {
props: {
trpcState: dehydrate(queryClient),
id,
},
};
}
```
Only cache pages that look the same for everyone. If the page depends on the user's cookie, leave out the `cache-control` header. See also the general [Response Caching](https://trpc.io/docs/server/caching) docs.
### API Response Caching with Next.js Adapter
You can also use `responseMeta` on the Next.js API handler to cache API responses directly:
```tsx no-check title="pages/api/trpc/[trpc].ts"
import { initTRPC } from '/server';
import * as trpcNext from '/server/adapters/next';
export const createContext = async ({
req,
res,
}: trpcNext.CreateNextContextOptions) => {
return {
req,
res,
};
};
type Context = Awaited<ReturnType<typeof createContext>>;
export const t = initTRPC.context<Context>().create();
export const appRouter = t.router({
public: t.router({
slowQueryCached: t.procedure.query(async (opts) => {
await new Promise((resolve) => setTimeout(resolve, 5000));
return {
lastUpdated: new Date().toJSON(),
};
}),
}),
});
export type AppRouter = typeof appRouter;
export default trpcNext.createNextApiHandler({
router: appRouter,
createContext,
responseMeta(opts) {
const { ctx, paths, errors, type } = opts;
// assuming you have all your public routes with the keyword `public` in them
const allPublic = paths && paths.every((path) => path.includes('public'));
// checking that no procedures errored
const allOk = errors.length === 0;
// checking we're doing a query request
const isQuery = type === 'query';
if (ctx?.res && allPublic && allOk && isQuery) {
// cache request for 1 day + revalidate once every second
const ONE_DAY_IN_SECONDS = 60 * 60 * 24;
return {
headers: new Headers([
[
'cache-control',
`s-maxage=1, stale-while-revalidate=${ONE_DAY_IN_SECONDS}`,
],
]),
};
}
return {};
},
});
```
## FAQ
### Q: Why does the client still fetch data the server prefetched?
TanStack React Query refetches stale data when a component mounts, when the window regains focus, and when the network reconnects. The query client from the [setup](https://trpc.io/docs/client/nextjs/pages-router/setup#4-create-a-query-client-factory) keeps data fresh for 30 seconds with `staleTime`, so the first render uses what the server sent. Raise `staleTime` to fetch less, or see [SSG](https://trpc.io/docs/client/nextjs/pages-router/ssg#stop-the-client-from-refetching) to turn refetching off.
### Q: Can I use `useSuspenseQuery`?
Yes, for queries the page prefetched. A query that isn't in the cache suspends during the server render too, and the server can't reach your API's relative URL. Prefetch it in `getServerSideProps`, or use `useQuery`, which only fetches in the browser.