Skip to content

Enhancement: more configurable collection path #340

Description

@florian-lefebvre

Current state

Currently, the path is always static except for the slug, specified by slugField.

The problem

Projects often require more custom paths, e.g.: locales, publish date

Proposal

Make the collection config an intersection like so (pseudocode):

type Collection = { /* base props */}
	& (
		{
			path: string // with * validation
			slugField: keyof fields	
		}
		| {
			path: (fields: Record<string, any>) => string // with * validation
			slugField: keyof fields // might not be required, we could manually specify the field by interpolation instead
		}
	)

Examples

Locales

export default defineConfig({
	// ...
	collections: {
		blog: collection({
			label: "Blog",
			path: ({ locale }) => `src/content/blog/${locale}/*`
			schema: {
				// ...
				locale: fields.text({ label: "Locale" }) // could be a relationship, whatever
			}
		})
	}
})

Publish date

export default defineConfig({
	// ...
	collections: {
		blog: collection({
			label: "Blog",
			path: ({ publishDate }) => `src/content/blog/${publishDate}.*`
			schema: {
				// ...
				publishDate: fields.date({ label: "Published date" })
			}
		})
	}
})

Activity

  1. JedWatson commented on Jun 26, 2023

    @JedWatson
    Member

    Noted on the use case here 👍

    We're pretty keen on keeping slugs static (i.e not having a function you need to call in order to know where entries live) because we need to load the content directly from GitHub over their API, and knowing that as part of static config is very helpful. The challenge with allowing a function to define it is that it becomes non-deterministic, while that's not the case in your examples above, there'd be nothing stopping you from doing:

    path: () => `/content/${date.now()}/*`
    

    ... chaos 💥

    However we've discussed supporting support multiple slugFields as an alternative, which may solve the problem:

    export default defineConfig({
    	// ...
    	collections: {
    		blog: collection({
    			// ...
    			slugFields: ['locale', 'slug'],
    			path: 'src/content/blog/*/*', // stars are replaced with values in order of the keys in the array above
    			schema: {
    				// ...
    				slug: fields.slug(/* ... */),
    				locale: fields.text({ label: "Locale" })
    			}
    		})
    	}
    })
    

    What do you think? sound useful? can you see any limitations with this approach, if we added it, that you couldn't work around?

  2. florian-lefebvre commented on Jun 26, 2023

    @florian-lefebvre
    ContributorAuthor

    Sounds good 👍, a few thoughts:

    • It should be also applied for usage with documents fields
    • Maybe we could push this a bit further with Typescript string interpolation

    Right now, path requires either * or ** to be included. By setting the slugFields, we could pass them to the path type to force including [locale] and [slug] in the path. I don't know if this is possible though 🤔.

    Wdyt?

  3. JedWatson commented on Jun 28, 2023

    @JedWatson
    Member

    I had a chat with the team today about it, and there's some nuance that we need to solve, but I've put it on the roadmap.

    I like your idea about using the name of the field in the path too, we'll see what we can come up with and whether that's possible 🙂

    Can you expand a bit more on what you mean by "it should be applied for usage with document fields" too?

  4. florian-lefebvre commented on Jun 28, 2023

    @florian-lefebvre
    ContributorAuthor

    Awesome thanks! My bad, I just checked my schema and I thought putting format: { contentField: 'document' } changed the key to set for the path. Nevermind!

  5. florian-lefebvre commented on Jul 26, 2023

    @florian-lefebvre
    ContributorAuthor

    Hey @JedWatson, hope you're doing well! Do you have any ETA about this? Or could I help in any way, maybe with some guidance? It has become a must for the project I'm working on and I can dedicate a bit of time to make changes to keystatic

  6. florian-lefebvre commented on Aug 1, 2023

    @florian-lefebvre
    ContributorAuthor

    Sounds good 👍, a few thoughts:

    • It should be also applied for usage with documents fields
    • Maybe we could push this a bit further with Typescript string interpolation

    Right now, path requires either * or ** to be included. By setting the slugFields, we could pass them to the path type to force including [locale] and [slug] in the path. I don't know if this is possible though 🤔.

    Wdyt?

    @JedWatson have a look at https://www.typescriptlang.org/play#code/C4TwDgpgBAxgFhGBrACgQ2HAPAZQDYCuA5lBAB7AQB2AJgM5R3ABOAllUQDRQAqpF1eoxbsiAPigBeKAG18xALr9KtBjIBQUKPJLkVQtFRBQA-FAAUfPYIYADACQBvQyAC+9pzvfOjr26e1CEgAuKCoIADcIZgBKKFDwqOZ1BU0AnjSEyOiAbnV1GAB7KiZYZggMCABhQrw8RGBWYqkoXCDlG2E2Dm50TA7VLtExczS6IIAxVgg8elCdGQVONLAMOFD4RFQ1tuJetbF1GNCIwtYaKQlHVzyC4tKyFphyypq6hqaqcxkAIjQf7g-ABGPyWUB+eEK5QAtgBaVhgOgEaEAensaBRADNCoUgWhmD8YnkgA

    Credits to SuperKXT on Matt's TS Wizards Discord server

    EDIT: more realistic version
    https://www.typescriptlang.org/play#code/C4TwDgpgBAxgFhGBrACgQ2HAPAZQDYCuA5lBAB7AQB2AJgM5R3ABOAllUQDRQAqpF1eoxbsiAPigBeKAG18xALr9KtBjIBQUKPJLkVQtFRBQA-FAAUfPYIYADACQBvQyAC+9pzvfOjr26e1CEgAuKCoIADcIZgBKKFDwqOZ1BU0AnjSEyOiAbnV1UEgoAGEAezw8RGBWUqpcIOUbYTYObnRMRtVm0QlpRzS6IIAxVgg8elCdGQU8rTAMOFD4RFQF+uI2hbE81zz1GFqmWGYIDAgyiqqaqikodd0BLqYWrih2uE6hZ57zNIPLmDVWqhC6VQHXe6bTBidRxSQSf5goFUPYHKhHMi3GAnM6gq61cyORjDUbjOihGQAIholIU3HmmFClPsNKgrhiewAZgQqODasdTpQylROawiFg0joGNYugAlRClZg0LDfVrdDhiThpd7Sx5CeUHJVYJAQEClTmBYh0biq8RarR4vnoz4MfpaLQyJBQdhQE1mi1ShShImDYgjMYTS1EOiehTTHJQBmLN4LGNIJS7NKudRicxo0VEYN-cpI67kqBu92e703P3mkol-HooOyNLu2AIZDvLCO5FpuOU0NEcNk2kyKgEAC2ACNonSGwC+7GqUnaTD20oZUJx9lmKl21ozCGSRHy72y8vByfRzNEwsskk2W33SDG07+7M2Ts4pWTsACMwNz5mKOz5GiRzGNI2KCuctQFoSaQAPSIVoAB6JjFouZZFu2aDBsSYakpGVI0vOSZMiylJsva7rTksOJCm+yKEgRw5EeWVIQLS9L3lAzJcWyMQ0a4WrsnkQA

  7. algora-pbc commented on Aug 8, 2023

    @algora-pbc
  8. aazam-gh commented on Aug 24, 2023

    @aazam-gh

    Hi is this issue still available to be worked on? Would love to give it a shot :)

  9. florian-lefebvre commented on Aug 25, 2023

    @florian-lefebvre
    ContributorAuthor
  10. aazam-gh commented on Aug 30, 2023

    @aazam-gh

    @florian-lefebvre any updates?

  11. florian-lefebvre commented on Aug 30, 2023

    @florian-lefebvre
    ContributorAuthor

    nope, I'm not on the team so waiting for an answer as well

  12. sravanth-space commented on Sep 5, 2023

    @sravanth-space
  13. florian-lefebvre commented on Sep 6, 2023

    @florian-lefebvre
    ContributorAuthor

    That's a bot, I think labelling has to be done by the keystatic team if they wish

  14. rishi-raj-jain commented on Nov 7, 2023

    @rishi-raj-jain

    @florian-lefebvre

    Is this open to work? Would love to crush this.

  15. reteps commented on Oct 20, 2024

    @reteps

    @JedWatson Any updates?

  16. Rodrigoue9 commented on Sep 8, 2026

    @Rodrigoue9

    Hi @JedWatson @timneutkens! 👋

    I would like to work on this enhancement to enable configurable dynamic collection paths.

    Proposed Implementation Plan:

    1. Schema & Config Types: Update Collection configuration types in @keystatic/core to support an intersection allowing path as either a validated pattern string or a dynamic callback (fields: Record<string, any>) => string (e.g. for locales and date-based paths).
    2. Path Resolution & Validation: Update reader and writer path resolution routines with wildcard (*) validation to guarantee deterministic entry lookup.
    3. Tests: Add unit tests in packages/keystatic covering dynamic path callbacks, slug generation, and serialization.

    Could you please assign this issue to me so I can proceed with the PR? Thank you!

  17. ssmurfgg04-gif commented on Sep 8, 2026

    @ssmurfgg04-gif

    /attempt

    I'd like to implement this. Proposal:

    API: extend collection config as suggested - path: string | ((fields: Record<string, any>) => string). The function form receives the item's initial field values at creation time (locale, date, category...), and today's string form becomes the degenerate case.

    Implementation sketch:

    1. In packages/core/src/config, resolve the path template once at config load; interpolated segments define the slug region, so the existing * validation keeps working unchanged.
    2. Repo backends (local + GitHub) receive the resolved prefix from the config layer - no per-IO changes required.
    3. Config parse-time validation: error clearly if a function path doesn't return a string containing *.
    4. Back-compat: string paths short-circuit to current behavior, zero migration for existing users.

    Tests: vitest cases for locale-prefixed paths, date-partitioned paths (posts/2026/*), slug uniqueness across prefixes, plus docs updates for the Astro/Next integration pages.

    Happy to open the config-types change as a small RFC PR first if you want to settle the API shape before the full implementation lands.

  18. Bryandero98 commented on Sep 9, 2026

    @Bryandero98

    Hi @JedWatson — I'd like to work on this. Rough plan based on your proposal above: extend the collection config type to accept path as either the current static string or a function (fields) => string, keep slugField as-is for the static case, and thread the function form through wherever the collection path is currently resolved (reader, writer, and the admin UI's item-creation flow) so it's computed from the item's field values at creation time. Happy to adjust scope if you had something narrower in mind — could I get this assigned before I start?

  19. Bryandero98 commented on Sep 9, 2026

    @Bryandero98

    Dug into the path-resolution code before starting, and want to flag a real design gap in the function-path idea before I write it: every "list existing items" path (app/utils.ts's tree walk, reader/generic.ts's collectionReader) works by listing one static directory and glob-matching filenames — it never has field values in hand before it locates a file. A bare path: (fields) => string cant be inverted for listing (chicken-and-egg: need the fields to compute the path, need the file to get the fields).

    Proposed shape that avoids that problem entirely:

    path:
      | `${string}/${Glob}` | `${string}/${Glob}/${string}` // existing static form, unchanged
      | {
          // static, glob-listable root — existing items are discovered by recursively
          // walking this directory, same as today just recursive instead of one level
          base: `${string}/${Glob}` | `${string}/${Glob}/${string}`;
          // computes a NEW item's path (relative structure under `base`) from its field
          // values at creation time; only consulted on write, never needed for listing
          resolve: (fields: Record<string, unknown>) => string;
        }

    slugField stays required in both forms. Listing/reading is untouched in spirit — it just recursively walks base instead of one level, and derives each item's identity from its file path the same way it does today. resolve only runs when creating a new item, so there's no inversion problem. This does deviate from the plain-function sketch above, but I don't think a bare function is implementable on the reader side without either parsing every file up front or introducing exactly this kind of static anchor — happy to hear if you had a different discovery mechanism in mind. Starting on this shape now; will adjust if you'd rather go a different direction.

  20. Bryandero98 commented on Sep 9, 2026

    @Bryandero98

    Opened #1622 — went with a different (safer) shape than the function-path sketch above; full reasoning is in the PR description and in my earlier comment here.

  21. tihu220 commented on Sep 16, 2026

    @tihu220

    @JedWatson I implemented this using the static shape you proposed earlier in this thread — slugFields mapped onto the * segments of path in order, no callbacks, entry paths stay fully deterministic from config: #1624.

    Summary of the design:

    • entry slug = the segment values joined with / (e.g. en/my-post), so tree listing, routing, the reader and both storage backends work unchanged
    • config validation enforces star-segment count == slugFields.length and that the last entry is the slugField
    • secondary slug fields validate as slug segments; the primary field is uniqueness-checked against the composite slug
    • fully backwards compatible for single slugField collections

    Happy to adjust the shape if you had something different in mind.

  22. frammawiliansyah commented on Oct 3, 2026

    @frammawiliansyah

    /attempt #340

    Opened PR #1638 implementing deterministic multi-wildcard collection paths (e.g. blog/*/*), matching exact segment depths (locale/slug) without dynamic functions to preserve static GitHub API schema loading as discussed. Unit tests across path parsing, slug validation, and reader filtering all pass.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    roadmapSomething we're planning to solve

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions