next-ssp
v0.1.1
Published
Server-side props pipeline for the Next.js pages router: a step engine with lifecycle hooks, deterministic error-to-status mapping, a React Query dehydration companion and a MetaHead component.
Maintainers
Readme
next-ssp
A server-side props pipeline for the Next.js pages router: a step engine
with lifecycle hooks, deterministic error-to-status mapping, an optional React
Query dehydration companion and a MetaHead component.
Pages router only. The package builds on getServerSideProps /
getStaticProps, which do not exist in the App Router — there are no plans for
an App Router counterpart (RSC loaders solve this differently). Peer next >= 14
covers the types only.
next-ssp— the core: step engine,errors[]/statusCodeprops contract, error serialization & sanitization, status strategy, meta head utilities, deep serialization pair. Knows nothing about sessions or React Query.next-ssp/react-query— optional companion (peer@tanstack/react-query@^5): creates theQueryClientbefore the pipeline, dehydrates the cache into props, collects per-query prefetch errors, re-hydrates on the client.next-ssp/react— optional client subpath (peerreact): theMetaHeadcomponent.
Installation
npm install next-ssp
# with the React Query companion:
npm install next-ssp @tanstack/react-queryPeer dependencies: next >= 14 (types only), optionally
@tanstack/react-query@^5 and react@^18 || ^19.
Requires Node.js 20+. Ships ESM and CommonJS builds; the CommonJS build works
without require(esm) support, so Jest and CommonJS custom servers need no
extra transform settings.
Core factory
import { createGetServerSideProps } from 'next-ssp'
const getServerSideProps = createGetServerSideProps({
// The single pipeline slot — session, SEO redirects, prefetch; the order is yours.
// Plain-function steps are error-caught automatically.
steps: [
mySessionStep, // return { redirect } / { notFound } to short-circuit
mySeoRedirectStep,
],
// One variadic sync option; async work belongs in steps.
metaHead: (context) => ({ title: 'Page' }),
metaHeadOptions: { resolveOrigin: () => 'https://example.com' },
// Injectable status strategy (default: deterministic 500 > 503 > 502 > 404 > 400 > min(4xx)).
determineStatusFromErrors: (errors) => 500,
// Project base props, merged under the core keys.
composeProps: (context) => ({ session: context.sessionData }),
hooks: {
onBegin: (context) => {},
onCaughtError: (error) => logger.error(error),
onDone: (context, sspTime) => logger.info({ sspTime }),
},
})Resulting props contract (core keys cannot be overridden by composeProps):
interface Props {
metaHead: IMetaHead
error?: IErrorObject // a single sanitized, JSON-safe step error
statusCode?: number // duplicated from the response status
// ...everything returned by composeProps
}One step error lands in props.error (sanitized in production). Projects that accumulate several errors
may fold them into a single AggregateError — toErrorObject serializes it as
{ name: 'AggregateError', message, errors: [...] }, so a dev UI can unfold
the list back.
Step engine
A step is a function or { name, stepFunction, atNextData?, catchError? }.
stepFunction may be an array — the functions run in parallel and the break
results are prioritized deterministically: error > redirect > notFound.
atNextData: false skips the step on /_next/data requests. catchError: true
converts a thrown error into the break result { error }.
Use the lower-level engine directly when the factory does not fit:
import { abstractGetServerSideProps, expandServerSideSteps } from 'next-ssp'Request path utilities
import { checkIsNextDataPath, checkIsStaticResourcePath, nextDataToRoutePath } from 'next-ssp'
checkIsNextDataPath('/_next/data/<buildId>/lakes.json') // true (type predicate)
checkIsStaticResourcePath('/_next/static/chunks/app.js') // true (type predicate)
nextDataToRoutePath('/_next/data/<buildId>/lakes.json?p=2') // '/lakes?p=2'nextDataToRoutePath maps a data request back to its page route path —
handy for logging and metrics in custom servers.
React Query companion
import { createGetServerSideProps, prefetchQuery } from 'next-ssp/react-query'
import { QueryClient } from '@tanstack/react-query'
const getServerSideProps = createGetServerSideProps({
createQueryClient: () => new QueryClient({ defaultOptions: myDefaults }),
configureQueryClient: (queryClient) => queryClient.setQueryData(['settings'], settings),
steps: async (context) => {
// Soft prefetch: the failure is recorded per-query and the page keeps rendering.
await prefetchQuery(context, { queryKey: ['news'], queryFn: fetchNews })
// Critical query — let the error break the pipeline instead.
await context.queryClient.fetchQuery({ queryKey: ['session'], queryFn: fetchSession })
},
metaHead: (context) => ({
title: context.queryClient.getQueryData(['news'])?.title,
}),
})The companion mixes into props: reactQueryDehydratedState (serialized cache)
and, when soft prefetches failed, reactQueryDehydratedErrors
({ queryKey, error }[]). Those errors also feed the status strategy.
Client re-hydration
import { SspQueryProvider } from 'next-ssp/react-query'
function QueryProvider({ dehydratedState, children }) {
return (
<SspQueryProvider
dehydratedState={dehydratedState}
isExcludedFromWarnQueryKey={queryKey => queryKey[0] === 'settings'}
devtoolsProps={{ initialIsOpen: false }}
>
{children}
</SspQueryProvider>
)
}SspQueryProvider creates the client QueryClient (createQueryClient is
injectable), re-hydrates the state, and in development warns about prefetched
queries no client observer consumed (isExcludedFromWarnQueryKey suppresses
known-false positives). The React Query devtools panel is rendered in
development only; pass devtoolsProps: false to disable it entirely
(@tanstack/react-query-devtools is an optional peer — the panel is
lazy-loaded and never reaches a production bundle).
The lower-level hydrateDehydratedState is still exported for custom providers:
import { hydrateDehydratedState } from 'next-ssp/react-query'MetaHead
import { MetaHead } from 'next-ssp/react'
<MetaHead {...pageProps.metaHead} />prepareMetaHead (used by the factory) joins keyword arrays, truncates long
descriptions on sentence boundaries and resolves relative canonical/image
through metaHeadOptions.resolveOrigin. The title is passed through untouched —
compose your own format ("Page | Project" and similar) at the call site.
For proxy infrastructures getRequestOrigin(req) is a ready-made strict
resolver (x-forwarded-proto/x-forwarded-host headers, memoized per
request); getRequestProtocol/getRequestHost are exported separately.
Deep serialization
undefined cannot cross the JSON boundary of getServerSideProps props.
serializeDeep replaces it with a non-printable sentinel and deserializeDeep
revives it (plus ISO date strings). Use the pair — never one of them alone.
Both are built on the exported deepTransform — a structure-preserving deep
map over arrays and plain objects (returns the original reference when nothing
changed), useful on its own for generic deep value mapping.
Status strategy
determineStatusFromErrors classifies errors structurally:
responseInfo.status marks an API error, name: 'ValidationError' marks a
validation error, anything else is unexpected (→ 500). Deterministic API
priority: 500 > honest 503 > other 5xx as 502 > 404 > min(4xx). API 5xx/404
win over validation errors.
Error pipeline: toErrorObject (full JSON-safe object) → status strategy →
sanitizeErrorObject → props. An injected strategy receives the full objects
(the step error first, then collected errors such as per-query prefetch
failures) and the context, so headers like Retry-After can be derived from
responseInfo.headers and set on context.res:
createGetServerSideProps({
determineStatusFromErrors: (errors, context) => {
const status = defaultDetermineStatus(errors)
if (status === 503) context.res.setHeader('Retry-After', retryAfterFrom(errors))
return status
},
})Those full objects are server-only: in production props keep just the client
contract (name, message, code, params, responseInfo.status, nested
inner/errors). The onCaughtError(error, context, stepName) hook receives
the raw thrown value for logging.
Custom strategies usually only tweak the classification and keep the priority ladder as is — compose it from the exported building block instead of copying:
import { determineStatusFromApiStatuses } from 'next-ssp'
// An empty list means "API errors without any known status" (e.g. a network
// failure) — 502, not 500.
determineStatusFromApiStatuses([503, 500]) // 500
determineStatusFromApiStatuses([]) // 502On /_next/data requests 5xx statuses are remapped to 203: Next.js fails to
render 5xx inside data JSON.
Error page helper
import { createGetStaticPropsForErrorPage } from 'next-ssp/react-query'
// The title format is the project's identity — compose it at the call site.
const { getStaticProps } = createGetStaticPropsForErrorPage({
title: '404 | My Project',
})License
MIT. Errors are serialized with
serialize-error (MIT): the
ESM build depends on it, the CommonJS build inlines it together with
non-error because both are
ESM-only — see THIRD-PARTY-NOTICES.md.
