본문 바로가기
버전: 11.x

서버 사이드 렌더링

비공식 베타 번역

이 페이지는 PageTurner AI로 번역되었습니다(베타). 프로젝트 공식 승인을 받지 않았습니다. 오류를 발견하셨나요? 문제 신고 →

SSR을 활성화하려면 createTRPCNext 설정 콜백에서 ssr: true를 설정하기만 하면 됩니다.

정보

SSR을 활성화하면 tRPC는 서버에서 모든 쿼리를 미리 가져오기 위해 getInitialProps를 사용합니다. 이로 인해 getServerSideProps를 사용할 때 이런 문제가 발생할 수 있으며, 이 해결은 우리의 권한 밖입니다.

 
대안으로 SSR을 비활성화된 상태(기본값)로 두고 서버 사이드 헬퍼를 사용해 getStaticProps 또는 getServerSideProps에서 쿼리를 미리 가져올 수 있습니다.

서버 사이드 렌더링 단계에서 쿼리를 올바르게 실행하려면 config 내에 추가 로직을 포함해야 합니다. 또한 Response Caching도 고려해 보세요.

utils/trpc.ts
tsx
import { httpBatchLink } from '@trpc/client';
import { createTRPCNext } from '@trpc/next';
import { ssrPrepass } from '@trpc/next/ssrPrepass';
import type { AppRouter } from './api/trpc/[trpc]';
 
export const trpc = createTRPCNext<AppRouter>({
ssr: true,
ssrPrepass,
config(info) {
const { ctx } = info;
if (typeof window !== 'undefined') {
// during client requests
return {
links: [
httpBatchLink({
url: '/api/trpc',
}),
],
};
}
 
return {
links: [
httpBatchLink({
// The server needs to know your app's full url
url: `${getBaseUrl()}/api/trpc`,
/**
* Set custom request headers on every request from tRPC
* @see https://trpc.io/docs/client/headers
*/
headers() {
if (!ctx?.req?.headers) {
return {};
}
// To use SSR properly, you need to forward client headers to the server
// This is so you can pass through things like cookies when we're server-side rendering
return {
cookie: ctx.req.headers.cookie,
};
},
}),
],
};
},
});
utils/trpc.ts
tsx
import { httpBatchLink } from '@trpc/client';
import { createTRPCNext } from '@trpc/next';
import { ssrPrepass } from '@trpc/next/ssrPrepass';
import type { AppRouter } from './api/trpc/[trpc]';
 
export const trpc = createTRPCNext<AppRouter>({
ssr: true,
ssrPrepass,
config(info) {
const { ctx } = info;
if (typeof window !== 'undefined') {
// during client requests
return {
links: [
httpBatchLink({
url: '/api/trpc',
}),
],
};
}
 
return {
links: [
httpBatchLink({
// The server needs to know your app's full url
url: `${getBaseUrl()}/api/trpc`,
/**
* Set custom request headers on every request from tRPC
* @see https://trpc.io/docs/client/headers
*/
headers() {
if (!ctx?.req?.headers) {
return {};
}
// To use SSR properly, you need to forward client headers to the server
// This is so you can pass through things like cookies when we're server-side rendering
return {
cookie: ctx.req.headers.cookie,
};
},
}),
],
};
},
});

또는 특정 요청에 따라 SSR을 조건부로 적용하려면 ssr에 콜백 함수를 전달할 수 있습니다. 이 콜백은 boolean을 반환하거나 boolean으로 resolve되는 Promise를 반환할 수 있습니다:

utils/trpc.ts
tsx
import { httpBatchLink } from '@trpc/client';
import { createTRPCNext } from '@trpc/next';
import { ssrPrepass } from '@trpc/next/ssrPrepass';
import type { AppRouter } from './api/trpc/[trpc]';
 
export const trpc = createTRPCNext<AppRouter>({
ssrPrepass,
config(info) {
const { ctx } = info;
if (typeof window !== 'undefined') {
// during client requests
return {
links: [
httpBatchLink({
url: '/api/trpc',
}),
],
};
}
 
return {
links: [
httpBatchLink({
// The server needs to know your app's full url
url: `${getBaseUrl()}/api/trpc`,
/**
* Set custom request headers on every request from tRPC
* @see https://trpc.io/docs/client/headers
*/
headers() {
if (!ctx?.req?.headers) {
return {};
}
// To use SSR properly, you need to forward client headers to the server
// This is so you can pass through things like cookies when we're server-side rendering
return {
cookie: ctx.req.headers.cookie,
};
},
}),
],
};
},
ssr(opts) {
// only SSR if the request is coming from a bot
return opts.ctx?.req?.headers['user-agent']?.includes('bot') ?? false;
},
});
utils/trpc.ts
tsx
import { httpBatchLink } from '@trpc/client';
import { createTRPCNext } from '@trpc/next';
import { ssrPrepass } from '@trpc/next/ssrPrepass';
import type { AppRouter } from './api/trpc/[trpc]';
 
export const trpc = createTRPCNext<AppRouter>({
ssrPrepass,
config(info) {
const { ctx } = info;
if (typeof window !== 'undefined') {
// during client requests
return {
links: [
httpBatchLink({
url: '/api/trpc',
}),
],
};
}
 
return {
links: [
httpBatchLink({
// The server needs to know your app's full url
url: `${getBaseUrl()}/api/trpc`,
/**
* Set custom request headers on every request from tRPC
* @see https://trpc.io/docs/client/headers
*/
headers() {
if (!ctx?.req?.headers) {
return {};
}
// To use SSR properly, you need to forward client headers to the server
// This is so you can pass through things like cookies when we're server-side rendering
return {
cookie: ctx.req.headers.cookie,
};
},
}),
],
};
},
ssr(opts) {
// only SSR if the request is coming from a bot
return opts.ctx?.req?.headers['user-agent']?.includes('bot') ?? false;
},
});
pages/_app.tsx
tsx
import { trpc } from '../utils/trpc';
import type { AppProps } from 'next/app';
import type { AppType } from 'next/app';
 
const MyApp: AppType = ({ Component, pageProps }: AppProps) => {
return <Component {...pageProps} />;
};
 
export default trpc.withTRPC(MyApp);
pages/_app.tsx
tsx
import { trpc } from '../utils/trpc';
import type { AppProps } from 'next/app';
import type { AppType } from 'next/app';
 
const MyApp: AppType = ({ Component, pageProps }: AppProps) => {
return <Component {...pageProps} />;
};
 
export default trpc.withTRPC(MyApp);

SSR과 응답 캐싱

앱에서 SSR을 활성화하면 Vercel과 같은 환경에서 앱 로딩이 느려질 수 있습니다. 하지만 SSG를 사용하지 않고도 전체 앱을 정적으로 렌더링할 수 있습니다. 자세한 내용은 이 트위터 스레드를 참조하세요.

SSR 응답에 대한 캐시 헤더를 설정하려면 createTRPCNextresponseMeta 콜백을 사용할 수 있습니다. responseMeta를 이용한 프레임워크 독립적인 캐싱에 대한 일반적인 내용은 응답 캐싱 문서도 참조하세요.

utils/trpc.tsx
tsx
import { httpBatchLink } from '@trpc/client';
import { createTRPCNext } from '@trpc/next';
import { ssrPrepass } from '@trpc/next/ssrPrepass';
import type { AppRouter } from '../server/routers/_app';
 
export const trpc = createTRPCNext<AppRouter>({
config() {
if (typeof window !== 'undefined') {
return {
links: [
httpBatchLink({
url: '/api/trpc',
}),
],
};
}
 
const url = process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}/api/trpc`
: 'http://localhost:3000/api/trpc';
 
return {
links: [
httpBatchLink({
url,
}),
],
};
},
ssr: true,
ssrPrepass,
responseMeta(opts) {
const { clientErrors } = opts;
 
if (clientErrors.length) {
// propagate http first error from API calls
return {
status: clientErrors[0].data?.httpStatus ?? 500,
};
}
 
// 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}`,
],
]),
};
},
});
utils/trpc.tsx
tsx
import { httpBatchLink } from '@trpc/client';
import { createTRPCNext } from '@trpc/next';
import { ssrPrepass } from '@trpc/next/ssrPrepass';
import type { AppRouter } from '../server/routers/_app';
 
export const trpc = createTRPCNext<AppRouter>({
config() {
if (typeof window !== 'undefined') {
return {
links: [
httpBatchLink({
url: '/api/trpc',
}),
],
};
}
 
const url = process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}/api/trpc`
: 'http://localhost:3000/api/trpc';
 
return {
links: [
httpBatchLink({
url,
}),
],
};
},
ssr: true,
ssrPrepass,
responseMeta(opts) {
const { clientErrors } = opts;
 
if (clientErrors.length) {
// propagate http first error from API calls
return {
status: clientErrors[0].data?.httpStatus ?? 500,
};
}
 
// 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}`,
],
]),
};
},
});

Next.js 어댑터를 이용한 API 응답 캐싱

Next.js API 핸들러에서 responseMeta를 사용해 API 응답을 직접 캐싱할 수도 있습니다:

pages/api/trpc/[trpc].ts
tsx
import { initTRPC } from '@trpc/server';
import * as trpcNext from '@trpc/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 {};
},
});
pages/api/trpc/[trpc].ts
tsx
import { initTRPC } from '@trpc/server';
import * as trpcNext from '@trpc/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: 클라이언트 헤더를 서버에 수동으로 전달해야 하는 이유는 무엇인가요? tRPC가 자동으로 처리해주지 않는 이유는?

SSR을 수행할 때 클라이언트 헤더를 서버에 전달하지 않는 경우는 드물지만, 헤더에 동적으로 내용을 추가해야 할 수 있습니다. 따라서 tRPC는 헤더 키 충돌 등에 대한 책임을 지지 않기로 했습니다.

Q: Node 18에서 SSR을 사용할 때 connection 헤더를 삭제해야 하는 이유는?

connection 헤더를 제거하지 않으면 데이터 가져오기가 TRPCClientError: fetch failed로 실패합니다. connection금지된 헤더 이름이기 때문입니다.

Q: 네트워크 탭에서 여전히 네트워크 요청이 보이는 이유는?

기본적으로 데이터 가져오기 훅에 사용하는 @tanstack/react-query는 SSR을 통해 초기 데이터를 이미 받아온 경우에도 마운트 시와 윈도우 포커스 시 데이터를 재요청합니다. 이는 데이터가 항상 최신 상태임을 보장하기 위함입니다. 이 동작을 비활성화하려면 SSG 페이지를 참조하세요.