vercel / next.js

The React Framework
https://nextjs.org
MIT License
121.81k stars 26.05k forks source link

[RFC] Dynamic Routes #7607

Closed Timer closed 4 years ago

Timer commented 4 years ago

Dynamic Routes

Background

Dynamic routing (also known as URL Slugs or Pretty/Clean URLs) has been a long-time requested feature of Next.js.

Current solutions involve placing a L7 proxy, custom server, or user-land middleware in-front of your application. None of these solutions offer a sufficiently ergonomic developer experience.

Additionally, users reaching for a custom server inadvertently opt-out of advanced framework-level features like per-page serverless functions.

Goals

  1. Leverage convention to provide URL Slug support that is easy to reason about
  2. Cover a majority of use-cases observed in the wild
  3. Eliminate the need of a custom server to support /blog/:post
  4. Validate <Link /> route transitions when possible
  5. Avoid an implementation that requires a route manifest
  6. Routes must be expressible through the filesystem

Proposal

Next.js should support named URL parameters that match an entire URL segment. These routes would be expressed via the filesystem:

  1. A filename or directory name that is wrapped with [] would be considered a named parameter
  2. Explicit route segments would take priority over dynamic segments, matched from left-to-right
  3. Route parameters would be required, never optional
  4. Route parameters will be merged into the query object (accessible from getInitialProps or router via withRouter) — these parameters can not be overridden by a query parameter

To help understand this proposal, let's examine the following file tree:

pages/
├── [root].js
├── blog/
│ └── [id].js
├── customers/
│ ├── [customer]/
│ │ ├── [post].js
│ │ ├── index.js
│ │ └── profile.js
│ ├── index.js
│ └── new.js
├── index.js
└── terms.js

Next.js would produce the following routes, registered in the following order:

;[
  { path: '/', page: '/index.js' },
  { path: '/blog/:id', page: '/blog/[id].js' },
  { path: '/customers', page: '/customers/index.js' },
  { path: '/customers/new', page: '/customers/new.js' },
  { path: '/customers/:customer', page: '/customers/[customer]/index.js' },
  {
    path: '/customers/:customer/profile',
    page: '/customers/[customer]/profile.js',
  },
  { path: '/customers/:customer/:post', page: '/customers/[customer]/[post].js' },
  { path: '/terms', page: '/terms.js' },
  { path: '/:root', page: '/[root].js' },
]

Usage Examples

These examples all assume a page with the filename pages/blog/[id].js:

Navigating to the Page with <Link />

<Link href="/blog/[id]" as="/blog/how-to-use-dynamic-routes">
  <a>
    Next.js: Dynamic Routing{' '}
    <span role="img" aria-label="Party Popper">
      🎉
    </span>
  </a>
</Link>

The above example will transition to the /blog/[id].js page and provide the following query object to the Router:

{
  id: 'how-to-use-dynamic-routes'
}

Reading Named Parameters from Router

import { useRouter } from 'next/router'

function BlogPost() {
  const router = useRouter()
  // `blogId` will be `'how-to-use-dynamic-routes'` when rendering
  // `/blog/how-to-use-dynamic-routes`
  const blogId = router.query.id
  return <main>This is blog post {blogId}.</main>
}

export default BlogPost

Note: you can also use withRouter.

Reading Named Parameters in getInitialProps

function BlogPost({ blogText }) {
  return <main>{blogText}</main>
}

BlogPost.getInitialProps = async function({ query }) {
  // `blogId` will be `'how-to-use-dynamic-routes'` when rendering
  // `/blog/how-to-use-dynamic-routes`
  const blogId = query.id

  const { text } = await fetch(
    '/api/blog/content?id=' + encodeURIComponent(blogId)
  ).then(res => res.json())

  return { blogText: text }
}

export default BlogPost

Caveats

Optional route parameters are not expressible through the filesystem.

You can emulate an optional route parameter by creating a stub page that exports the parameter version (or vice versa). This increases the visibility of your application's routes when inspecting the filesystem.

// pages/blog/comments.js
// (the optional version of `pages/blog/[id]/comments.js`)
export { default } from './[id]/comments.js'

Named parameters cannot appear in the middle of a route name.

This means a page named blog-[id].js would be interpreted literally and not matched by /blog-1. You can either restructure your page to be /blog/[id].js or turn the entire URL Segment into a named parameter and handle stripping blog- in your application's code.

Alternatives

Denote URL Slugs with insert symbol here instead of []

There are very few symbols available for use to represent a named parameter on the filesystem. Unfortunately, the most recognized way of defining a named parameter (:name) is not a valid filename.

While surveying prior art, the most common symbols used to denote a parameter were _, $ and [].

We ruled out _ because _ is typically indicative of an internal route that is not publicly routable (e.g. _app, _document, /_src, /_logs). We also ruled out $ because it is a sigil in bash for parameter expansion.

Leverage path-to-regexp for comprehensive support

Most of the symbols required to express regex are not valid filenames. Additionally, complex regexes are sensitive to route ordering for prioritization. The filesystem cannot express order nor contain regex symbols.

In the future, we may allow path-to-regexp routes defined in next.config.js or similar. This is currently out of scope for this proposal.

Future Exploration

Catch-All Parameters

In the future, we may consider adding catch-all parameters. With what we know thus far, these parameters must be at the end of the URL and would potentially use % to denote a catch-all route (e.g. pages/website-builder/[customerName]/%.tsx).

Timer commented 4 years ago

Poll: To express interest in optional parameters, please react with a "+1" this comment.

Note: Optional parameters are already possible with this RFC, they just do not have an explicit syntax (see Caveats section).

Timer commented 4 years ago

Poll: To express interest in catch-all parameters, please react with a "+1" this comment.

Note: Please share your use case for catch-all parameters in this thread! We'd love to understand the problem-space more.

Timer commented 4 years ago

reserved 3

ValentinH commented 4 years ago

On ricardo.ch, we use a locale prefix for each route which make routing a bit more complex.

Example of valid routes:

Do your think such prefix parameters could be supported?

At the moment, we use https://www.npmjs.com/package/next-routes

Another thing: for the article page, we also support a slug before the id like /de/article/example-article-123 where the id would be 123. This is done via a quite complex regex using next-routes and I don't see how this could be expressed with a file-system API.

Timer commented 4 years ago

@ValentinH the provided routes are all possible using the filesystem API -- given your provided routes:


we also support a slug before the id like /de/article/example-article-123 where the id would be 123

This use case is addressed above:

Named parameters cannot appear in the middle of a route name.

This means a page named blog-$id.js would be interpreted literally and not matched by /blog-1. You can either restructure your pages to be /blog/$id.js or turn the entire URL Segment into a named parameter and handle stripping blog- in your application's code.

Does this solution not meet your needs? We'd love to learn more about your specific requirements.

ValentinH commented 4 years ago

Thanks a lot for the answer.

I didn't thought about using $locale/index.js both as a folder and a file, this is really neat!

Regarding the "named parameter in middle", I overlooked it because I thought having the slug being dynamic was different. However, you are completely right and this is addressed by the paragraph you mentioned. Striping the slug in the application code will be the way to go 🙂

jpstrikesback commented 4 years ago

Would something like this (parse params from .hidden .files/.folders) be possible?

pages/
├── .root.js
├── blog/
│ ├── .id/
│ │ ├── index.js
│ │ └── comments.js <-- optional?
├── customers/
│ ├── .customer/
│ │ ├── .post/
│ │ │ └── index.js
│ │ ├── index.js
│ │ └── profile.js
│ ├── index.js
│ └── new.js
├── index.js
└── terms.js

or leave the $ so one could find their files :D but always use $folder to indicate a param?

pages/
├── $root.js
├── blog/
│ ├── $id/
│ │ ├── index.js
│ │ └── comments.js <-- optional?
├── customers/
│ ├── $customer/
│ │ ├── $post/
│ │ │ └── index.js
│ │ ├── index.js
│ │ └── profile.js
│ ├── index.js
│ └── new.js
├── index.js
└── terms.js
Janpot commented 4 years ago

I used to have this use-case for optional parameters in an app that worked with npm packages. These could optionally have a scope. There are routes like:

So basically, the scope parameter is optional, but it's also only a scope when it starts with @. So /packages/express/dependencies and /packages/@babel/core have the same amount of segments, but in one case it's /dependencies of express and in the other it's /index of @babel/core.

In the end it was solved in react-router with the following routes:

<Switch>
  <Route path={`/packages/`} exact component={PackagesOverview} />
  <Route path={`/packages/:name(@[^/]+/[^/]+)`} component={PackageView} />
  <Route path={`/packages/:name`} component={PackageView} />
</Switch>

I'm not sure I see a solution for this use-case in this RFC.

As for catch-all use cases, I'm thinking any deep linking into recursively nested data, like folder structures, treeviews, treemaps.

timdp commented 4 years ago

My 2 cents: dollar signs in filenames are a bad idea because they're used by shells as a sigil. You're going to confuse people trying to run rm $root.js. Underscores seem like a decent alternative.

More broadly: like many people, I've tried to leverage the file system as a solution to this in the past. Ultimately, I think the file system is never going to offer the full expressiveness you're looking for. For example, declarative routers usually let you specify a validation pattern for a dynamic parameter. In that case, part of the schema lives on the file system, and another part in the code. Separation of concerns is a good thing, but in this case, it's a technical limitation more than anything else.

amesas commented 4 years ago

Like @ValentinH we use the $locale var, but it's optional.

Should we use /page.ts and /page/$locale/page.ts?

Because we can use a "default" locale or a predefined locale ( user settings ), in those cases we don't use the $locale param.

But we have more use cases: /car/search/$optional-filter-1/$optional-filter-2/$optional-filter-3

Where optional-filter-1: color-red, optional-filter-2: brand-ford, etc...

And for optional params, something like /$required-param/ and /$$optional-param/?

nervetattoo commented 4 years ago

Awesome that this is coming up on the roadmap!

I have to chime in supporting @timdp though. When you can't even touch $file this will lead to a lot of confusion. You need to remember escaping at every interaction. touch \$file; vim $file will open vim without a file (because $file isn't a defined variable). Likewise tab completion in a shell will list all variables, once again bringing confusion.

I'm proposing two alternatives that I feel gives the right associations and should work in shells:

pfyod commented 4 years ago

Another option would be to use curly braces (unless they are reserved characters on some file systems). This parameter syntax is also "prior art" and is used by many other routers:

pages/
├── {root}.js
├── blog/
│ └── {id}.js
├── customers/
│ ├── {customer}/
│ │ ├── {post}.js
│ │ ├── index.js
│ │ └── profile.js
│ ├── index.js
│ └── new.js
├── index.js
└── terms.js

This would allow to have parameters in the middle of the route segment and multiple parameters per segment as it's clear where the parameter starts and where it ends, e.g. /product-{productId}-{productColor}.

stephan281094 commented 4 years ago

So excited that dynamic routes is coming to Next.js!

Regarding the syntax for named parameters, this is something that has been discussed on Spectrum: https://spectrum.chat/next-js/general/rfc-move-parameterized-routing-to-the-file-system~ce289c5e-ff66-4a5b-8e49-08548adfa9c7. It might be worth using that as input for the discussion here. Personally, I like how Sapper is doing it using [brackets]. This is also something Nuxt is going to implement in version 3. Having different frameworks use the same format for dynamic filesystem-based routes sounds like a good thing.

Regarding the usage of <Link />, I think developers will easily forget to set both the href and as attributes. I get that it's not possible to "merge" these into the href attribute because it'd introduce a breaking change, but I feel like it could be solved in a more elegant way.

timdp commented 4 years ago

Curly braces are unfortunately used by Bash to group commands.

ValentinH commented 4 years ago

I agree with @stephan281094 regarding usage of <Link />, it will be source of mistakes.

Deevian commented 4 years ago

Dynamic routing is an extremely useful feature, so it's really awesome you guys have looked into it and came up with a solution, huge props!

While on this topic, wildcard routes would also be a worthy addition to the proposal. You did mention catch-all parameters as something to investigate in the future, but it doesn't cover cases where you might want to do something like /category/*, which could have N number of levels, and you want all of them to render the category page.

rauchg commented 4 years ago

Is it possible to use : safely ? If so, that'd be my vote, because everyone is already familiar with that convention from express.

rauchg commented 4 years ago

Due to $ conflicting with shell variables, I personally strongly oppose it.

stephan281094 commented 4 years ago

Is it possible to use : safely ? If so, that'd be my vote, because everyone is already familiar with that convention from express.

Apparently : is a prohibited character in Windows, so it's probably not safe. Going with _ isn't ideal either, since underscores can be used in URLs. The reason I think [brackets] are a nice solution, is because it's more future proof. If Next.js wants to support routes like post-12345 in the future, using this syntax it can be done without introducing a breaking change.

Janpot commented 4 years ago

So a list of characters to avoid would be:

Anything else?

AndrewIngram commented 4 years ago

This wouldn't eliminate our need to have a centralised route file for a couple of reasons:

We also generate our pages folder for these reasons:

Essentially our pattern is to use our centralised route configuration to generate our pages folder, which contains files which do nothing more than import/export modules from elsewhere in the codebase.

To that end, my focus is more on whether this proposal can work simply as an enhanced output format for our existing page generation process, so that we can at least get the benefit of not needing a custom server.

I've gone over some of my use cases elsewhere: https://gist.github.com/AndrewIngram/8d4c4ccd9bd10415a375caacade9f5ca

The main thing i'm not seeing is supporting implicit parameters that aren't expressed in the file-system, for example URL overrides.

Let's say we have a URL like this:

/some-vanity-url/

Where in current Next.js terms, we'd want it to map to a product page with a number of query parameters, e.g Product.js?id=foo&language=en.

Similarly, on our website most countries "sites" are scoped by a top-level segment eg es or ie, but the gb site is mounted without that segment. This means all the gb pages have an implicit country parameter, whilst for all other countries it's explicit.

The other downside, is that because in our case, the same 'page' can exist at multiple mount points in the URL architecture, we're going to end up with a greater number of bundles (i.e. several duplicate entry points) than we actually need in practice.

On the whole this proposal seems like it's going to work well for most common use cases, but it doesn't obviate the need for a route config or custom server in all cases. But assuming this doesn't replace my ability to use the framework the way I do today, I don't have any real objection to this being the preferred happy-path API.

scf4 commented 4 years ago

I support the {id} suggestion. It allows for multiple params and I think it looks a lot better. It also fits better with React.

pkrawc commented 4 years ago

I'm in favor of the file/&param.js character. Taken directly from urls and it doesn't look like it conflicts with files systems or bash.

poeticninja commented 4 years ago

I would use _ and maybe allow for an override in the next.config.js for those who reallllly need something different.

Appreciate the work on this. Been wanting it for a while! ❤️

doguh commented 4 years ago

Amazing! 🎉🎉🎉

My only issue here is that Link needs both href and as params.

I believe we could just write <Link to="blog/123" /> : since Nextjs already knows all the routes based on files in the pages folder, it could easily translate it into "/blog/$id".

nervetattoo commented 4 years ago

So a list of characters to avoid would be:

& is a control operator in bash that runs the left side of the argument in an async subshell. Plaintext: open pages/&customer would run open pages/ in the background and the command customer in the foreground shell.

jamestalmage commented 4 years ago

This looks really cool.

It does seem like this will create a significant number of single file directories (like /blog/$id in the original example). This gets even more cumbersome if you want two trailing route parameters (i.e. /git/compare/$hash1/$hash2).

I also don't love that the filename for rending a blog post would be $id.js. Having it named blog.js would be much more descriptive.

Perhaps combine with a @customRoute decorator?

// pages/blog.js
import {useRouter, @customRoute} from 'next/router'

@customRoute('/blog/:id')
function BlogPost() {
  const router = useRouter()
  // `blogId` will be `'how-to-use-dynamic-routes'` when rendering
  // `/blog/how-to-use-dynamic-routes`
  const blogId = router.query.id
  return <main>This is blog post {blogId}.</main>
}

export default BlogPost

This seems to provide a cleaner solution for the proposed catch-all parameters as well.

itsMapleLeaf commented 4 years ago

Decorators can't be applied to functions (maybe this changed since I last read it?) and the proposal is probably a long way away anyway

Janpot commented 4 years ago

Well, suppose you go that road, you'd probably do it the way AMP is configured now:

// /pages/blog.js
export const config = {
  amp: true,
  dynamicRoute: true // adds a [blog] property to the query object
  // dynamicRoute: /\d+/ // could even support regex if you want
};

However, I think stuff like this can be added later on if it seems useful at some point. I think I'd rather see a basic support to start with, much as is described in the RFC. Get some real usage with that, then refine where it breaks. I also think the only characters that should be taken into account to avoid are the file system ones. Those are the real blockers for building this feature.

ematipico commented 4 years ago

Please, make sure to use a character that is friendly with serverless solutions! (On Aws, there are some characters that could cause troubles)

pkrawc commented 4 years ago

Exporting a config object with a component key is something I don't hate.

You could also just use a HOC

function BlogPost(props) {
    return <div />
}

export default withCustomRoute(BlogPost, "/blog/:id")
dmytro-lymarenko commented 4 years ago

what if we add some static field to the page (like getInitialProps)?

// pages/blog.js
import {useRouter} from 'next/router'

function BlogPost() {
  const router = useRouter()
  // `blogId` will be `'how-to-use-dynamic-routes'` when rendering
  // `/blog/how-to-use-dynamic-routes`
  const blogId = router.query.id
  return <main>This is blog post {blogId}.</main>
}

// By default it would be as it is now
BlogPost.route = '/blog/:id';

export default BlogPost
itsMapleLeaf commented 4 years ago

@dmytro-lymarenko What happens when you navigate to /blog in the browser? A 404?

Janpot commented 4 years ago

Because this needs to be determined at compile time, I guess you'd need something that is statically analyzable. a HOC or a static property wouldn't be.

jamestalmage commented 4 years ago

you'd need something that is statically analyzable. a HOC or a static property wouldn't be

Every static property example given so far would be statically analyzable (though you could certainly break things easily). We could just insist that you export your function and set the route property on it in a statically analyzable way. The runtime could check for route properties that are set at runtime but weren't caught by our static analyzer and issue a warning / throw an error.

jamestalmage commented 4 years ago

What happens when you navigate to /blog in the browser? A 404?

@kingdaro - IMO, yes. If you want to use both /blog, and /blog/:blogId paths, then you use a directory. You are overloading that path, so the directory structure is justified.

pages/
├── blog/
│ ├── $id.js
│ └── index.js
merelinguist commented 4 years ago

A quick review of all the character options mentioned so far:

In order of appearance

Char Drawbacks
$ dollar signs in filenames are a bad idea because they're used by shells as a sigil
_ typically indicative of an internal route
=
@ splat operator in PowerShell
{...} used by Bash to group commands
[...] filename generation in zsh
: not a valid filename
& is a control operator in bash that runs the left side of the argument in an async subshell
huv1k commented 4 years ago

Well, suppose you go that road, you'd probably do it the way AMP is configured now:

// /pages/blog.js
export const config = {
  amp: true,
  dynamicRoute: true // adds a [blog] property to the query object
  // dynamicRoute: /\d+/ // could even support regex if you want
};

However, I think stuff like this can be added later on if it seems useful at some point. I think I'd rather see a basic support to start with, much as is described in the RFC. Get some real usage with that, then refine where it breaks. I also think the only characters that should be taken into account to avoid are the file system ones. Those are the real blockers for building this feature.

I think using config is a bad idea because you need to go through multiple files, to see what is actually dynamic. If you set it in the file system you can see it from the first glance.

scf4 commented 4 years ago

I wonder if more than one standard routing solution should be something to consider.

Simple file-based routing is a great selling point for those new to Next/React, or anyone wanting to quickly get a simple app up and running, but it can be rather limiting. And it seems to me that trying to shoehorn dynamic routing into this pattern could ruin that simplicity and lead to unnecessary complexity, all in the name of keeping everything file-based.

After reading this discussion and thinking about my own usage of Next.js, I think first class support for an alternative (supplementary) routing system could be the best way to solve this.

I like some of the out-of-the-box thinking in this thread (such as the proposal to use decorators) but those ideas definitely have their own problems. I hope we can come up with something great 👍

scf4 commented 4 years ago

Exporting a config object with a component key is something I don't hate.

You could also just use a HOC

function BlogPost(props) {
    return <div />
}

export default withCustomRoute(BlogPost, "/blog/:id")

That’s pretty cool, but I wonder if having route information split across many files like this could become hard to manage.

jamestalmage commented 4 years ago

My original thinking with proposing a local config (in the file) vs a global one (route.js), was to address the specific scenarios mentioned in my first comment (deeply nested files that are the only file in their directory, non-semantic file names, and catch-all-params).

If used strictly in those contexts, it's far less confusing, because the URL maps directly onto the file system, and only "extra" params are addressed by the local config.

That said, I'm not sure I would even try to to restrict users from doing it however they want. We can pretty print the calculated routing table to the console, or even save it to some predetermined file. That should be enough to aid troubleshooting routes

nervetattoo commented 4 years ago

@merelinguist I don't believe = is prohibited in Windows as you've written in the summary table. You are linking back to how : is prohibited, but according to Microsoft Windows file naming docs the equal character is allowed.

remy commented 4 years ago

I'm already porting with dynamic routes in a project that I use in production (hopefully I can get it live this week).

Specific question though, will the new next@canary API feature also support dynamic routing?

{ path: '/api/:customer', page: '/api/$customer/index.js' }

I've just tried it with next@8.1.1-canary.54 and I get a 404 not found, so I suspect it's not there yet. Just seems like it makes sense for these two features (API + dynamic routes) to have parity on URL routing.

huv1k commented 4 years ago

@remy it's not yet implemented it's on my list to do it soon

dmytro-lymarenko commented 4 years ago

We also should take into account not only Windows and Linux systems, but others too: https://en.wikipedia.org/wiki/Filename#Comparison_of_filename_limitations

dmytro-lymarenko commented 4 years ago

I'd like to add more info about my proposal:

what if we add some static field to the page (like getInitialProps)?

// pages/blog.js
import {useRouter} from 'next/router'

function BlogPost() {
  const router = useRouter()
  // `blogId` will be `'how-to-use-dynamic-routes'` when rendering
  // `/blog/how-to-use-dynamic-routes`
  const blogId = router.query.id
  return <main>This is blog post {blogId}.</main>
}

// By default it would be as it is now
BlogPost.route = '/blog/:id';

export default BlogPost
  1. Developer can't use runtime variable for that route property
    const route = `/blog/${somethingElse}`;
    BlogPost.route = route; // is not allowed
  2. When we build page manifest with this current RFC (where folder contains some character to identify it is dynamic) I don't see the difference if we build this page manifest with reading the file and find the static route property on the page. In the same way lingui works: they don't allow id for Trans to be dynamic
    <Trans id="msg.docs" /* id can only be static string */>
    Read the <a href="https://lingui.js.org">documentation</a>
    for more info.
    </Trans>
remy commented 4 years ago

Going by the list of prefixes already listed - I wonder if there's any strong reason not to use a @ symbol prefix?

I doubt if it's of value, but you get parity with Nuxt - which means someone switching from one or the other will immediately know how it works.

Alternatively, has anyone thought about making the prefix a user option? It makes it harder for people to understand one project from another, but it means if I wanted, I could make the prefix query__{...} or something.

Just a thought.

scf4 commented 4 years ago

Following on from @remy's suggestion, why not completely open up the API for how Next parses routes from the file system. Giving users as much (or as little) flexibility as they need, and inspiring reliable third-party routing solutions.

revskill10 commented 4 years ago

@scf4 I had a library which is a PoC , which use now.json routes config to do universal routing with nextjs too here

I hope that Zeit team also open source the route parser on client side library, too.

nataliyakarat commented 4 years ago

Looking at Nuxt I think _id.js is not too bad. Yes, we already use _app and _document.js as you mentioned and it's not publicly routable. But a dynamic route can also be viewed as not routable as this is a template for many pages