#What is Proper Way to Have Breadcrumb URLs Work?

19 messages · Page 1 of 1 (latest)

dreamy topaz
#

Problem: When I try to use the auto-generated Breadcrumb URL, the server returns a 404.

Setup:

  1. npx create-payload-app with PostgreSQL
  2. Default seed
  3. Add posts to nestedDocsPlugin collections
  4. Setup a parent -> child -> grandchild relationship between existing posts

Observed Behavior: The Breadcrumbs field automatically generates what appears to be the correct url, i.e. /<parent>/<child>/<grandchild> when looking at the post that is the grandchild. Using that URL results in a 404. Using just the /<grandchild> segment loads the grandchild post.

Attempted Fixes: I have tried going into src/app/(frontend)/posts/[slug]/page.tsx and change the where property of the payload.find function call inside the queryPostBySlug function to also check to see if a breadcrumb with a URL like the slug exists. However, this section of code is never hit when trying to use one of the nested Breadcrumb URLs.

**Potential Next Step?: ** My next thought is that I might need to change how a post auto-generates its slug? But wouldn't that also change how the Breadcrumb generates its URLs and break that part? I'm kinda lost at what to do next.

hard isleBOT
dreamy topaz
#

Oh, I have also tried similar with the default pages collection to the same effect.

granite creek
#

Your route [slug] only catches single segments like /posts/grandchild, not nested paths like /posts/parent/child/grandchild.
Solution: Use a catch-all route with [...slug]:

Rename folder:
mv src/app/(frontend)/posts/[slug] src/app/(frontend)/posts/[...slug]

Update page.tsx:
``
// src/app/(frontend)/posts/[...slug]/page.tsx
interface PageProps {
params: {
slug: string[] // Now an array
}
}

async function queryPostBySlug(slugSegments: string[]) {
const payload = await getPayload({ config })
const fullPath = '/' + slugSegments.join('/')

const posts = await payload.find({
collection: 'posts',
where: {
'breadcrumbs.url': {
equals: fullPath,
},
},
limit: 1,
})

return posts.docs[0] || null
}

export default async function PostPage({ params }: PageProps) {
const post = await queryPostBySlug(params.slug)

if (!post) {
notFound()
}

return (
<article>
<h1>{post.title}</h1>
{/* Your content */}
</article>
)
}
Optional - Support both nested and direct URLs:
async function queryPostBySlug(slugSegments: string[]) {
const payload = await getPayload({ config })

if (slugSegments.length === 1) {
// Check both slug and breadcrumb for single segment
const posts = await payload.find({
collection: 'posts',
where: {
or: [
{ slug: { equals: slugSegments[0] } },
{ 'breadcrumbs.url': { equals: '/' + slugSegments[0] } }
],
},
limit: 1,
})
return posts.docs[0] || null
}

// For nested paths, only check breadcrumb
const fullPath = '/' + slugSegments.join('/')
const posts = await payload.find({
collection: 'posts',
where: { 'breadcrumbs.url': { equals: fullPath } },
limit: 1,
})

return posts.docs[0] || null
}
``

frosty sundial
#

So here's how I ended up solving for this. I'm using multi-tenant as well so that's taken into consideration as well. This is in progress and I've had to step away from it for a few days so if it doesn't work, I'll ping back when I work on it again.

  const { isEnabled: draft } = await draftMode()
  const params = await paramsPromise
  console.log({ params })
  let slug: string | undefined = undefined


  let breadCrumb: string | undefined = undefined
  if (params?.slug && Array.isArray(params.slug)) {
    breadCrumb = `/${params.slug.join('/')}`
    slug = params.slug[params.slug.length - 1]
  } else if (typeof params?.slug === 'string') {
    breadCrumb = `/${params.slug}`
    slug = params.slug
  }

  const headers = await getHeaders()
  const subdomain = headers.get('host')?.split('.')[0] || null
  const payload = await getPayload({ config: configPromise })
  const { user } = await payload.auth({ headers })
  
  console.log({ slug })

  const slugConstraint: Where = slug
    ? {
        slug: {
          equals: slug,
        },
        'breadcrumbs.url': {
          equals: breadCrumb,
        },
      }
    : {
        or: [
          {
            slug: {
              equals: '',
            },
          },
          {
            slug: {
              equals: 'home',
            },
          },
          {
            slug: {
              exists: false,
            },
          },
        ],
      }

  const pageQuery = await payload.find({
    collection: 'pages',
    overrideAccess: true,
    user,
    draft,
    where: {
      and: [
        {
          'tenant.domain': {
            equals: subdomain,
          },
        },
        slugConstraint,
      ],
    },
  })

  console.log({ pageQuery })
  const pageData = pageQuery.docs?.[0]
#

This is also for the root [...slug] route and not the nested posts specifically, but same should apply.

dreamy topaz
dreamy topaz
# granite creek Your route [slug] only catches single segments like /posts/grandchild, not neste...

yup, this post helped me get it working! the only additional thing i had to do was to modify my generateStaticParams to return an array of url segments from the breadcrumbs url; by default, this function only returns the slug of the document

export async function generateStaticParams() {
  const payload = await getPayload({ config: configPromise })
  const posts = await payload.find({
    collection: 'posts',
    draft: false,
    limit: 1000,
    overrideAccess: false,
    pagination: false,
    select: {
      slug: true,
      breadcrumbs: {
        url: true,
      },
    },
  })

  const params = posts.docs.map(({ slug, breadcrumbs }) => {
    if(breadcrumbs && breadcrumbs.length > 0 && breadcrumbs[0].url) {
      return { slug: breadcrumbs[0].url.split('/') }
    }
    return { slug: [slug] }
  })

  return params
}
hard isleBOT
granite creek
#

Perfect! Yeah, generateStaticParams needs to match the route structure. Thanks for posting the complete solution!

#

Just heads up - split('/') on a URL like /parent/child will give you an empty string as the first element. You might want to add .filter(Boolean) or .slice(1) after the split. But if it's working, Next.js might be handling it fine.

dreamy topaz
granite creek
#

Nice debugging!

lofty cairn
#

did anyone else run into issues with their link component only rendering the slug but not the parents or grandparents when navigating?

dreamy topaz
#

i noticed various issues with how i was doing things before when i started up a new project, so my fixes are below. i simplified queryTopicBySlug and modified generateStaticParams because i learned that the last breadcrumb is always the one with the full path.

i also changed the where clause from or to and to ensure that i am getting the page with a matching slug and a breadcrumb matching the full path. i noticed that i was sometimes getting inconsistent matching results and this was why. originally, the 'breadcrumbs.url': { : fullPath, }, part could match to any record in the collection that has the full path of the desired page. this mean that it could select a child of the current page. so, i made it match both the shortest slug of the current page and the full path in the breadcrumb to help guard against multiple records with the same slug.

#
import React, { cache } from 'react'
import { LivePreviewListener } from '@/components/LivePreviewListener'
import PageClient from './page.client'
import configPromise from '@payload-config'
import { draftMode } from 'next/headers'
import { getPayload } from 'payload'

export async function generateStaticParams() {
  const payload = await getPayload({ config: configPromise })

  const posts = await payload.find({
    collection: 'topics',
    draft: false,
    depth: 1,
    limit: 1000,
    overrideAccess: false,
    pagination: false,
    select: {
      slug: true,
      breadcrumbs: {
        url: true,
      },
    },
  })

  const params = posts.docs.map(({ slug, breadcrumbs }) => {
    if (breadcrumbs && breadcrumbs.length > 1) {
      return { slug: breadcrumbs.pop()?.url?.split('/').filter(Boolean) }
    }

    return { slug: [slug] }
  })

  return params
}

type Args = {
  params: Promise<{
    slug?: string[]
  }>
}

export default async function Topic({ params: paramsPromise }: Args) {
  const { isEnabled: draft } = await draftMode()
  const { slug = [''] } = await paramsPromise
  const topic = await queryTopicBySlug({ slug })

  return (
    <article>
      <PageClient />

      {draft && <LivePreviewListener />}

      <h2>{topic.title}</h2>
    </article>
  )
}

const queryTopicBySlug = cache(async ({ slug }: { slug: string[] }) => {
  const { isEnabled: draft } = await draftMode()
  const payload = await getPayload({ config: configPromise })
  const fullPath = '/' + slug.join('/')

  const result = await payload.find({
    collection: 'topics',
    draft,
    limit: 1,
    overrideAccess: draft,
    pagination: false,
    where: {
      and: [
        {
          slug: {
            equals: slug.pop(),
          },
        },
        {
          'breadcrumbs.url': {
            equals: fullPath,
          },
        },
      ],
    },
  })

  return result.docs?.[0] || null
})
woeful dew
ember abyss
#

and some examples to use Folders for breadcrumbs? @woeful dew @ashen summit. I will search for it by messages, but mb you know some

ashen summit