You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Enhancement: more configurable collection path #340
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):
typeCollection={/* base props */}&({path: string// with * validationslugField: keyoffields}|{path: (fields: Record<string,any>)=>string// with * validationslugField: keyoffields// might not be required, we could manually specify the field by interpolation instead})
Examples
Locales
exportdefaultdefineConfig({// ...collections: {blog: collection({label: "Blog",path: ({ locale })=>`src/content/blog/${locale}/*`schema: {// ...locale: fields.text({label: "Locale"})// could be a relationship, whatever}})}})
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?
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 🤔.
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!
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
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 🤔.
I would like to work on this enhancement to enable configurable dynamic collection paths.
Proposed Implementation Plan:
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).
Path Resolution & Validation: Update reader and writer path resolution routines with wildcard (*) validation to guarantee deterministic entry lookup.
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!
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:
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.
Repo backends (local + GitHub) receive the resolved prefix from the config layer - no per-IO changes required.
Config parse-time validation: error clearly if a function path doesn't return a string containing *.
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.
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?
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 levelbase: `${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.
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.
@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.
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.
Activity
JedWatson commentedon Jun 26, 2023
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:
... chaos 💥
However we've discussed supporting support multiple
slugFieldsas an alternative, which may solve the problem:What do you think? sound useful? can you see any limitations with this approach, if we added it, that you couldn't work around?
florian-lefebvre commentedon Jun 26, 2023
Sounds good 👍, a few thoughts:
Right now,
pathrequires 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 commentedon Jun 28, 2023
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?
florian-lefebvre commentedon Jun 28, 2023
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!florian-lefebvre commentedon Jul 26, 2023
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
florian-lefebvre commentedon Aug 1, 2023
@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
algora-pbc commentedon Aug 8, 2023
aazam-gh commentedon Aug 24, 2023
Hi is this issue still available to be worked on? Would love to give it a shot :)
florian-lefebvre commentedon Aug 25, 2023
cc @JedWatson
aazam-gh commentedon Aug 30, 2023
@florian-lefebvre any updates?
florian-lefebvre commentedon Aug 30, 2023
nope, I'm not on the team so waiting for an answer as well
sravanth-space commentedon Sep 5, 2023
florian-lefebvre commentedon Sep 6, 2023
That's a bot, I think labelling has to be done by the keystatic team if they wish
rishi-raj-jain commentedon Nov 7, 2023
@florian-lefebvre
Is this open to work? Would love to crush this.
reteps commentedon Oct 20, 2024
@JedWatson Any updates?
Rodrigoue9 commentedon Sep 8, 2026
Hi @JedWatson @timneutkens! 👋
I would like to work on this enhancement to enable configurable dynamic collection paths.
Proposed Implementation Plan:
Collectionconfiguration types in@keystatic/coreto support an intersection allowingpathas either a validated pattern string or a dynamic callback(fields: Record<string, any>) => string(e.g. for locales and date-based paths).*) validation to guarantee deterministic entry lookup.packages/keystaticcovering dynamic path callbacks, slug generation, and serialization.Could you please assign this issue to me so I can proceed with the PR? Thank you!
ssmurfgg04-gif commentedon Sep 8, 2026
/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:
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.*.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.
Bryandero98 commentedon Sep 9, 2026
Hi @JedWatson — I'd like to work on this. Rough plan based on your proposal above: extend the collection config type to accept
pathas either the current static string or a function(fields) => string, keepslugFieldas-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?Bryandero98 commentedon Sep 9, 2026
Dug into the path-resolution code before starting, and want to flag a real design gap in the function-
pathidea before I write it: every "list existing items" path (app/utils.ts's tree walk,reader/generic.ts'scollectionReader) works by listing one static directory and glob-matching filenames — it never has field values in hand before it locates a file. A barepath: (fields) => stringcant 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:
slugFieldstays required in both forms. Listing/reading is untouched in spirit — it just recursively walksbaseinstead of one level, and derives each item's identity from its file path the same way it does today.resolveonly 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.Bryandero98 commentedon Sep 9, 2026
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.
tihu220 commentedon Sep 16, 2026
@JedWatson I implemented this using the static shape you proposed earlier in this thread —
slugFieldsmapped onto the*segments ofpathin order, no callbacks, entry paths stay fully deterministic from config: #1624.Summary of the design:
/(e.g.en/my-post), so tree listing, routing, the reader and both storage backends work unchangedslugFields.lengthand that the last entry is theslugFieldslugFieldcollectionsHappy to adjust the shape if you had something different in mind.
frammawiliansyah commentedon Oct 3, 2026
/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.