Skip to content

Instantly share code, notes, and snippets.

@Pagebakers
Last active November 10, 2024 15:26
Show Gist options
  • Save Pagebakers/f39f154e5bb6b03c477cb4a1d88ac937 to your computer and use it in GitHub Desktop.
Save Pagebakers/f39f154e5bb6b03c477cb4a1d88ac937 to your computer and use it in GitHub Desktop.
Next.js createPage helper with loader pattern
import { AnyZodObject, z } from 'zod'
import { Metadata, ResolvingMetadata } from 'next'
type InferParams<Params> = Params extends readonly string[]
? {
[K in Params[number]]: string
}
: Params extends AnyZodObject
? z.infer<Params>
: unknown
type LoaderFn<
Params extends readonly string[] | AnyZodObject,
SearchParams extends readonly string[] | AnyZodObject,
> = (args: {
params: InferParams<Params>
searchParams: InferParams<SearchParams>
}) => Promise<any>
type InferLoaderData<Loader> = Loader extends (args: any) => Promise<infer T>
? T
: unknown
export interface CreatePageProps<
Params extends readonly string[] | AnyZodObject,
SearchParams extends readonly string[] | AnyZodObject,
Loader extends LoaderFn<Params, SearchParams> = LoaderFn<
Params,
SearchParams
>,
> {
params?: Params
searchParams?: SearchParams
loader?: Loader
metadata?:
| Metadata
| ((
args: {
params: InferParams<Params>
searchParams: InferParams<SearchParams>
data: InferLoaderData<Loader>
},
parent: ResolvingMetadata,
) => Promise<Metadata>)
component: React.ComponentType<{
params: InferParams<Params>
searchParams?: InferParams<SearchParams>
data: InferLoaderData<Loader>
}>
}
function parseParams<Schema extends readonly string[] | AnyZodObject>(
params: Record<string, string>,
schema?: Schema,
) {
if (schema && 'parse' in schema) {
return schema.parse(params) as InferParams<Schema>
}
return params as InferParams<Schema>
}
export const createPage = <
const Params extends readonly string[] | AnyZodObject,
const SearchParams extends readonly string[] | AnyZodObject,
Loader extends LoaderFn<Params, SearchParams> = LoaderFn<
Params,
SearchParams
>,
>(
props: CreatePageProps<Params, SearchParams, Loader>,
) => {
const {
params: paramsSchema,
searchParams: searchParamsSchema,
component: PageComponent,
loader,
metadata,
} = props
// We don't really care about the types here since it's internal
async function Page(props: any) {
const params = parseParams(props.params, paramsSchema)
const searchParams = parseParams(props.searchParams, searchParamsSchema)
let pageProps: any = {
params,
searchParams,
}
if (typeof loader === 'function') {
const data = await loader(pageProps)
pageProps = {
...pageProps,
data,
}
}
return <PageComponent {...pageProps} />
}
if (typeof metadata === 'function') {
return {
generateMetadata: async (
{
params,
searchParams,
}: {
params: InferParams<Params>
searchParams: InferParams<SearchParams>
},
parent: ResolvingMetadata,
) => {
const data =
typeof loader === 'function'
? await loader({
params,
searchParams,
})
: undefined
return metadata(
{
params,
searchParams,
data,
},
parent,
)
},
Page,
}
}
return {
metadata,
Page,
}
}
@tmoran-stenoa
Copy link

Cool stuff! I’d recommend adding the capability to redirect if an error occurs in the loader (e.g., resource not found). This is a pattern that comes up again and again in my code:

// path: /pets/[petId]
function MyPetPage(props: {petId: string}) {
   const query = usePetQuery(petId)
   
   const router = useRouter()
   useEffect(() => {
         if (!query.isLoading && !query.data) router.push("/pets")
   }, [query.isLoading, query.data])

   if (!query.data) return <Loader />

   return <MyPetPageInner pet={query.data} />
}

This can all be tremendously simplified using your createPage wrapper, but there would still need to be a way to redirect if the pet doesn't exist.

As for the questions you asked, my answers may not be very useful because I don’t use SSR nor the app router, but here goes anyways:

Use safeParse for the params and searchParams?

If the page needs a param to render and somehow it’s not there, then there’s not much you can do other than throw an error, so I don’t see what the benefits would be.

Can we hydrate the data returned from the loader function easily to React Query?

You mean to pass the server-side loaded data to the client-side? Interesting… I don’t use React Query nor SSR but seems like that should be handled at a lower level, by React Query itself perhaps

cache the loader function?

I would not want it to be cached as I’d want all cache to go into SWR/React Query.

@Pagebakers
Copy link
Author

Cool stuff! I’d recommend adding the capability to redirect if an error occurs in the loader (e.g., resource not found). This is a pattern that comes up again and again in my code:

// path: /pets/[petId]
function MyPetPage(props: {petId: string}) {
   const query = usePetQuery(petId)
   
   const router = useRouter()
   useEffect(() => {
         if (!query.isLoading && !query.data) router.push("/pets")
   }, [query.isLoading, query.data])

   if (!query.data) return <Loader />

   return <MyPetPageInner pet={query.data} />
}

This can all be tremendously simplified using your createPage wrapper, but there would still need to be a way to redirect if the pet doesn't exist.

You can do this in the component handler:

import { redirect } from 'next/navigation'
//...
component: ({data}) {
  if (!data.workspace) {
    redirect('/')
  }
}

As for the questions you asked, my answers may not be very useful because I don’t use SSR nor the app router, but here goes anyways:

Use safeParse for the params and searchParams?

If the page needs a param to render and somehow it’s not there, then there’s not much you can do other than throw an error, so I don’t see what the benefits would be.

I agree params should be strict in this regard. But searchParams i'm not so sure, but then again, you can make your validation rules less strict in that case. The use case for not throwing an error would be to have more forgiving (for the end user) error handling in the UI, by keeping the UI functional instead just showing an error page.

Can we hydrate the data returned from the loader function easily to React Query?

You mean to pass the server-side loaded data to the client-side? Interesting… I don’t use React Query nor SSR but seems like that should be handled at a lower level, by React Query itself perhaps

Yep that would speed up initial page load a bit because the data doesnt need to be loaded after the client is finished loading. The result can be passed into initialData option of useQuery, but wondering if there's another way.

cache the loader function?

I would not want it to be cached as I’d want all cache to go into SWR/React Query.

Since the loader is called twice in this setup, in generateMetaData and in the component it would be beneficial, the data

@Pagebakers
Copy link
Author

@magicspon
Copy link

FYI, you have a typo in your gist:

generateMetaData should be generateMetadata

@Pagebakers
Copy link
Author

Thanks @magicspon , I've updated it.

@magicspon
Copy link

Guess we need a next 15 version. Promisify all the things!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment