Simplify your stack and build anything. Or everything.
Build tomorrow’s web with a modern solution you truly own.
Code-based nature means you can build on top of it to power anything.
It’s time to take back your content infrastructure.

What is Proper Way to Have Breadcrumb URLs Work?

default discord avatar
kibner8 months ago
12
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.



Oh, I have also tried similar with the default

pages

collection to the same effect.

  • default discord avatar
    nolow_591238 months ago

    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 }
  • default discord avatar
    tonyzups8 months ago

    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.

  • default discord avatar
    kibner8 months ago

    ohhh, i didn't know the naming of the directory could have an effect like that! i think that is the missing piece. will let you know if i stumble and can't pick myself up when trying this. Thanks!



    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
    }
  • default discord avatar
    nolow_591238 months ago

    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.

  • default discord avatar
    kibner8 months ago

    yeah, i actually originally wrote my code assuming that behavior (because i tested it in a dev console in my browser, first). turns out, that assumption broke my code because Next.js or Node (not sure which) removed the empty string element

  • default discord avatar
    nolow_591238 months ago

    Nice debugging!

  • default discord avatar
    eric_956538 months ago

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

  • default discord avatar
    kibner7 months ago

    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
    })
  • default discord avatar
    rilrom7 months ago
  • default discord avatar
    zegie7 months ago

    and some examples to use Folders for breadcrumbs?

    @127418710519578624

    @654031862146007055

    . I will search for it by messages, but mb you know some

  • default discord avatar
    zed05477 months ago

    If I can't find an example with folders, I'll try to find some time this week to make an example of using folders instead of nested-docs 👍 good shout

  • default discord avatar
    zegie7 months ago

    i heard about this, but cant find nothig, so thank you! For now, fill try your repo with nesteddocs

Star on GitHub

Star

Chat on Discord

Discord

online

Can't find what you're looking for?

Get dedicated engineering support directly from the Payload team.